@spark-apps/quickpeek 1.2.0 → 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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Configuration types and defaults for QuickPeek
3
3
  */
4
- declare const VERSION = "1.1.1";
4
+ declare const VERSION = "1.2.1";
5
5
  declare const CONFIG_FILE = "quickpeek.config.json";
6
6
  type UserTier = 'free' | 'pro';
7
7
  interface Config {
@@ -27,6 +27,8 @@ interface Config {
27
27
  contrast: number;
28
28
  tempo: number;
29
29
  stepDelay: number;
30
+ outro?: string;
31
+ zoom?: number;
30
32
  };
31
33
  audio?: {
32
34
  voice?: string;
@@ -91,18 +93,48 @@ interface AIError {
91
93
  isRateLimit?: boolean;
92
94
  isAuthError?: boolean;
93
95
  retryAfterSeconds?: number;
96
+ /** Upstream HTTP status, when the failure was an HTTP response. */
97
+ status?: number;
98
+ /** Host that produced the failure, so relay vs backend is distinguishable. */
99
+ host?: string;
100
+ /** Raw response body (truncated), for the error log. */
101
+ detail?: string;
94
102
  }
95
103
  type AIResult = AIResponse | AIError;
104
+ /** Called before each backoff so the CLI can explain a long pause. */
105
+ type RetryNotice = (info: {
106
+ attempt: number;
107
+ status: number;
108
+ delayMs: number;
109
+ host: string;
110
+ }) => void;
111
+ interface ChatOptions {
112
+ onRetry?: RetryNotice;
113
+ /**
114
+ * Backoff before each 5xx retry. Defaults to SERVER_BACKOFF_MS, which is
115
+ * sized to fit inside a serverless function budget — callAI runs inside the
116
+ * quickpeek-web routes as well as the CLI. Interactive callers should pass
117
+ * CLI_BACKOFF_MS, which can afford to outwait the backend's closed window.
118
+ */
119
+ backoffMs?: number[];
120
+ }
121
+ /** Short enough to stay well inside a serverless maxDuration. */
122
+ declare const SERVER_BACKOFF_MS: number[];
123
+ /**
124
+ * Long enough to outlast the AI backend's ~45-60s closed window (measured
125
+ * 16 Aug 2026). Only safe where a human is waiting on a spinner.
126
+ */
127
+ declare const CLI_BACKOFF_MS: number[];
96
128
  /**
97
129
  * Direct Spark AI call using the runtime SPARK_AI_KEY (dev + server-side use).
98
130
  */
99
- declare function callAI(systemPrompt: string, userPrompt: string, tier?: UserTier): Promise<AIResult>;
131
+ declare function callAI(systemPrompt: string, userPrompt: string, tier?: UserTier, opts?: ChatOptions): Promise<AIResult>;
100
132
  /**
101
133
  * Relayed Spark AI call for the published CLI: authenticates with the user's
102
134
  * registered email via quickpeek-web, which holds the real key server-side
103
135
  * and enforces per-user daily limits.
104
136
  */
105
- declare function callAIViaRelay(systemPrompt: string, userPrompt: string, email: string, tier?: UserTier): Promise<AIResult>;
137
+ declare function callAIViaRelay(systemPrompt: string, userPrompt: string, email: string, tier?: UserTier, opts?: ChatOptions): Promise<AIResult>;
106
138
  /**
107
139
  * Parse AI-generated plan JSON from response text
108
140
  */
@@ -179,6 +211,15 @@ interface Plan {
179
211
  * Page crawling and interactive element extraction
180
212
  */
181
213
 
214
+ /**
215
+ * A selector matching one id.
216
+ *
217
+ * HTML ids are far more permissive than CSS identifiers: `description-(optional)`
218
+ * is a perfectly legal id, but `#description-(optional)` is invalid CSS and
219
+ * Playwright rejects it, so the step silently finds nothing. Anything that is
220
+ * not identifier-safe gets the attribute form instead.
221
+ */
222
+ declare function idSelector(id: string): string;
182
223
  /**
183
224
  * Crawl a page and extract interactive elements with smart selectors
184
225
  * Results are cached in-memory for a few minutes to avoid duplicate browser launches
@@ -187,9 +228,13 @@ interface Plan {
187
228
  */
188
229
  declare function crawlPage(url: string, task?: (text: string) => {
189
230
  succeed: (text?: string) => void;
190
- }, storageStatePath?: string): Promise<{
231
+ }, storageStatePath?: string, viewport?: {
232
+ width: number;
233
+ height: number;
234
+ }): Promise<{
191
235
  title: string;
192
236
  elements: ElementInfo[];
237
+ scrollable: number;
193
238
  }>;
194
239
 
195
240
  /**
@@ -229,7 +274,7 @@ declare function toTitleCase(text: string): string;
229
274
  */
230
275
 
231
276
  declare function buildSystemPrompt(maxSteps: number): string;
232
- declare function buildUserPrompt(url: string, title: string, elements: ElementInfo[], langName: string | null, description: string | undefined, maxSteps: number): string;
277
+ declare function buildUserPrompt(url: string, title: string, elements: ElementInfo[], langName: string | null, description: string | undefined, maxSteps: number, scrollable?: number): string;
233
278
 
234
279
  type Tier = 'free' | 'pro';
235
280
  declare const FEATURES: Record<string, string[]>;
@@ -287,4 +332,4 @@ declare function isExcludedLink(text: string, href?: string): boolean;
287
332
  /** Check if a link should be skipped during crawling */
288
333
  declare function shouldSkipLink(text: string, href: string, currentPath: string): boolean;
289
334
 
290
- export { type AIError as A, isPaid as B, type Config as C, DEFAULT_CAPTIONS as D, EXCLUDED_LINK_PATTERNS as E, FEATURES as F, isVerified as G, type HighlightMode as H, INTERACTIVE_SELECTORS as I, normalizeUrl as J, parseAIPlanResponse as K, parsePartial as L, pricingUrl as M, rgbaToHex as N, shouldSkipLink as O, type Plan as P, showPaywall as Q, type RateLimitInfo as R, type SparkStatus as S, type Tier as T, type UserTier as U, VERSION as V, toTitleCase as W, getTier as X, type AIResponse as a, type AIResult as b, CONFIG_FILE as c, type CaptionStyle as d, type CaptionWordStyle as e, DEFAULT_CONFIG as f, type ElementInfo as g, type ElementType as h, type SparkSubscription as i, type SparkTrial as j, type Step as k, type SuggestedAction as l, buildSystemPrompt as m, buildUserPrompt as n, callAI as o, callAIViaRelay as p, crawlPage as q, getErrorMessage as r, getLanguageName as s, getStatus as t, getStatusCached as u, getTierByEmail as v, hasAccess as w, hasTrialRemaining as x, isCanceling as y, isExcludedLink as z };
335
+ export { toTitleCase as $, type AIError as A, hasAccess as B, type Config as C, DEFAULT_CAPTIONS as D, EXCLUDED_LINK_PATTERNS as E, FEATURES as F, hasTrialRemaining as G, type HighlightMode as H, INTERACTIVE_SELECTORS as I, idSelector as J, isCanceling as K, isExcludedLink as L, isPaid as M, isVerified as N, normalizeUrl as O, type Plan as P, parseAIPlanResponse as Q, type RateLimitInfo as R, SERVER_BACKOFF_MS as S, type Tier as T, type UserTier as U, VERSION as V, parsePartial as W, pricingUrl as X, rgbaToHex as Y, shouldSkipLink as Z, showPaywall as _, type AIResponse as a, getTier as a0, type AIResult as b, CLI_BACKOFF_MS as c, CONFIG_FILE as d, type CaptionStyle as e, type CaptionWordStyle as f, type ChatOptions as g, DEFAULT_CONFIG as h, type ElementInfo as i, type ElementType as j, type RetryNotice as k, type SparkStatus as l, type SparkSubscription as m, type SparkTrial as n, type Step as o, type SuggestedAction as p, buildSystemPrompt as q, buildUserPrompt as r, callAI as s, callAIViaRelay as t, crawlPage as u, getErrorMessage as v, getLanguageName as w, getStatus as x, getStatusCached as y, getTierByEmail as z };
package/dist/web.d.mts CHANGED
@@ -1,6 +1,46 @@
1
- export { A as AIError, a as AIResponse, b as AIResult, E as EXCLUDED_LINK_PATTERNS, g as ElementInfo, h as ElementType, F as FEATURES, H as HighlightMode, I as INTERACTIVE_SELECTORS, P as Plan, R as RateLimitInfo, S as SparkStatus, i as SparkSubscription, j as SparkTrial, k as Step, l as SuggestedAction, T as Tier, m as buildSystemPrompt, n as buildUserPrompt, o as callAI, q as crawlPage, r as getErrorMessage, s as getLanguageName, t as getStatus, u as getStatusCached, X as getTier, v as getTierByEmail, w as hasAccess, x as hasTrialRemaining, y as isCanceling, z as isExcludedLink, B as isPaid, G as isVerified, J as normalizeUrl, K as parseAIPlanResponse, L as parsePartial, M as pricingUrl, N as rgbaToHex, O as shouldSkipLink, Q as showPaywall, W as toTitleCase } from './scraping-BK8pIJhm.mjs';
1
+ import { P as Plan } from './scraping-BgepklP3.mjs';
2
+ export { A as AIError, a as AIResponse, b as AIResult, E as EXCLUDED_LINK_PATTERNS, i as ElementInfo, j as ElementType, F as FEATURES, H as HighlightMode, I as INTERACTIVE_SELECTORS, R as RateLimitInfo, l as SparkStatus, m as SparkSubscription, n as SparkTrial, o as Step, p as SuggestedAction, T as Tier, q as buildSystemPrompt, r as buildUserPrompt, s as callAI, u as crawlPage, v as getErrorMessage, w as getLanguageName, x as getStatus, y as getStatusCached, a0 as getTier, z as getTierByEmail, B as hasAccess, G as hasTrialRemaining, K as isCanceling, L as isExcludedLink, M as isPaid, N as isVerified, O as normalizeUrl, Q as parseAIPlanResponse, W as parsePartial, X as pricingUrl, Y as rgbaToHex, Z as shouldSkipLink, _ as showPaywall, $ as toTitleCase } from './scraping-BgepklP3.mjs';
2
3
  export { WIDE_VOICES as LANGUAGE_VOICES, hexToAss as hexToASS, windowsDrivePathToWsl as windowsPathToWSL } from '@spark-apps/video-kit';
3
4
 
5
+ /**
6
+ * The plan file, and handing it to the browser editor.
7
+ *
8
+ * The JSON on disk is the source of truth: the CLI records from it, and the
9
+ * editor is a view onto it. The handover travels in a URL fragment, which no
10
+ * browser sends to a server — so editing a demo costs no upload, no account,
11
+ * and leaves no stored copy that can drift from the file being recorded.
12
+ *
13
+ * Mirrors VidLet's lib/project validate + file-io split, so whoever maintains
14
+ * both codebases finds the same shape in each.
15
+ */
16
+
17
+ type ParseResult = {
18
+ ok: true;
19
+ plan: Plan;
20
+ } | {
21
+ ok: false;
22
+ error: string;
23
+ };
24
+ /**
25
+ * Browsers accept far longer URLs than this, but shells, terminals and the
26
+ * `open` handlers in between are the weak link. Past it, the editor is opened
27
+ * empty and the user picks the file by hand.
28
+ */
29
+ declare const MAX_HANDOFF_URL_CHARS = 32000;
30
+ /** Canonical on-disk form: 2-space JSON with a trailing newline. */
31
+ declare function serializePlan(plan: Plan): string;
32
+ /**
33
+ * Parse plan JSON, rejecting anything the recorder could not act on. Steps
34
+ * missing an id are numbered rather than refused: the editor drops ids when
35
+ * a user adds a step by hand, and renumbering is what the CLI would do too.
36
+ */
37
+ declare function parsePlanText(text: string): ParseResult;
38
+ declare function encodePlan(plan: Plan): string;
39
+ /** Decode a `#plan=` fragment. Same validation as reading the file. */
40
+ declare function decodePlan(encoded: string): ParseResult;
41
+ /** The editor URL carrying a plan, or null when it is too long to hand over. */
42
+ declare function planEditorUrl(baseUrl: string, plan: Plan): string | null;
43
+
4
44
  /**
5
45
  * Plan Validation - Shared between CLI and Web
6
46
  * Contains validation logic, prompts, and value preservation utilities
@@ -100,4 +140,4 @@ declare function restorePreservedValues<T extends BaseStep & {
100
140
  mediaIdx: number;
101
141
  };
102
142
 
103
- export { ACTIONS_REQUIRING_TARGET, type BaseStep, type CLIPlan, type CLIStep, FIX_PLAN_SYSTEM_PROMPT, INVALID_TARGETS, type PreservedValues, REFINE_PLAN_SYSTEM_PROMPT, type StepAction, type StepFailure, collectPreservedValues, filterValidSteps, isInvalidTarget, isValidStep, restorePreservedValues, validateStep };
143
+ export { ACTIONS_REQUIRING_TARGET, type BaseStep, type CLIPlan, type CLIStep, FIX_PLAN_SYSTEM_PROMPT, INVALID_TARGETS, MAX_HANDOFF_URL_CHARS, type ParseResult, Plan, type PreservedValues, REFINE_PLAN_SYSTEM_PROMPT, type StepAction, type StepFailure, collectPreservedValues, decodePlan, encodePlan, filterValidSteps, isInvalidTarget, isValidStep, parsePlanText, planEditorUrl, restorePreservedValues, serializePlan, validateStep };
package/dist/web.mjs CHANGED
@@ -75,7 +75,7 @@ function getErrorMessage(err) {
75
75
  return err instanceof Error ? err.message : String(err);
76
76
  }
77
77
  function toTitleCase(text) {
78
- return text.replace(/\w+/g, (word, index) => {
78
+ return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
79
79
  if (index === 0) {
80
80
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
81
81
  }
@@ -87,13 +87,30 @@ function toTitleCase(text) {
87
87
  }
88
88
 
89
89
  // src/lib/api-client.ts
90
+ var SERVER_BACKOFF_MS = [2e3, 4e3];
90
91
  var SPARK_AI_BASE = "https://sparkbrain.app";
91
92
  var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
92
93
  var SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
93
94
  var RELAY_BASE = process.env.QUICKPEEK_API_BASE || "https://quickpeek.co";
94
95
  var RELAY_TOKEN_URL = `${RELAY_BASE}/api/cli/token`;
95
96
  var RELAY_CHAT_URL = `${RELAY_BASE}/api/cli/ai`;
96
- var API_KEY = process.env.SPARK_AI_KEY || "";
97
+ function apiKey() {
98
+ return process.env.SPARK_AI_KEY || "";
99
+ }
100
+ function hostOf(url) {
101
+ try {
102
+ return new URL(url).host;
103
+ } catch {
104
+ return "AI service";
105
+ }
106
+ }
107
+ function firstLine(text) {
108
+ return truncate((text ?? "").trim().split("\n")[0] ?? "", 200);
109
+ }
110
+ function truncate(text, max) {
111
+ const trimmed = (text ?? "").trim();
112
+ return trimmed.length > max ? `${trimmed.slice(0, max)}\u2026` : trimmed;
113
+ }
97
114
  var cachedDirectToken = null;
98
115
  async function getOrRefreshDirectToken() {
99
116
  const now = Date.now();
@@ -103,7 +120,7 @@ async function getOrRefreshDirectToken() {
103
120
  try {
104
121
  const response = await fetch(SPARK_AI_TOKEN, {
105
122
  method: "POST",
106
- headers: { "X-Api-Key": API_KEY }
123
+ headers: { "X-Api-Key": apiKey() }
107
124
  });
108
125
  if (!response.ok) {
109
126
  throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
@@ -121,8 +138,10 @@ async function getOrRefreshDirectToken() {
121
138
  throw new Error(`Failed to get Spark AI token: ${getErrorMessage(err)}`);
122
139
  }
123
140
  }
124
- async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier) {
125
- let lastRateLimitError = null;
141
+ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
142
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
143
+ let lastRetriableError = null;
144
+ const host = hostOf(chatUrl);
126
145
  for (let attempt = 1; attempt <= 3; attempt++) {
127
146
  try {
128
147
  const response = await fetch(chatUrl, {
@@ -138,36 +157,53 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
138
157
  ]
139
158
  })
140
159
  });
160
+ const bodyText = await response.text().catch(() => "");
141
161
  let data = {};
142
- const contentType = response.headers.get("content-type") ?? "";
143
- if (contentType.includes("application/json")) {
144
- data = await response.json();
162
+ try {
163
+ data = bodyText ? JSON.parse(bodyText) : {};
164
+ } catch {
145
165
  }
146
166
  if (!response.ok) {
147
- const error = data.error || `Spark AI error: ${response.status} ${response.statusText}`;
148
- if (response.status === 429 && attempt < 3) {
149
- const retryAfter = response.headers.get("Retry-After");
150
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * 2 ** (attempt - 1);
151
- lastRateLimitError = {
167
+ const upstream = data.error || firstLine(bodyText) || response.statusText || "no error body";
168
+ const error = `${host} ${response.status}: ${upstream}`;
169
+ const detail = truncate(bodyText, 800);
170
+ const isRetriable = response.status === 429 || response.status >= 500;
171
+ if (isRetriable && attempt < 3) {
172
+ const retryAfter2 = response.headers.get("Retry-After");
173
+ const delayMs = retryAfter2 ? parseInt(retryAfter2, 10) * 1e3 : response.status >= 500 ? backoffMs[attempt - 1] ?? backoffMs[backoffMs.length - 1] ?? 2e3 : 1e3 * 2 ** (attempt - 1);
174
+ onRetry?.({ attempt, status: response.status, delayMs, host });
175
+ lastRetriableError = {
152
176
  success: false,
153
177
  error,
154
- isRateLimit: true,
155
- ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
178
+ isRateLimit: response.status === 429,
179
+ status: response.status,
180
+ host,
181
+ ...detail ? { detail } : {},
182
+ ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
156
183
  };
157
184
  await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 3e4)));
158
185
  continue;
159
186
  }
187
+ const retryAfter = response.headers.get("Retry-After");
188
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : Number.NaN;
160
189
  return {
161
190
  success: false,
162
191
  error,
163
192
  isRateLimit: response.status === 429,
164
- isAuthError: response.status === 401 || response.status === 403
193
+ isAuthError: response.status === 401 || response.status === 403,
194
+ status: response.status,
195
+ host,
196
+ ...detail ? { detail } : {},
197
+ ...Number.isFinite(retryAfterSeconds) ? { retryAfterSeconds } : {}
165
198
  };
166
199
  }
167
200
  if (!data.success || !data.response) {
168
201
  return {
169
202
  success: false,
170
- error: data.error || "No response from Spark AI"
203
+ error: `${host} 200 but no content: ${data.error || firstLine(bodyText) || "empty response"}`,
204
+ status: response.status,
205
+ host,
206
+ ...bodyText ? { detail: truncate(bodyText, 800) } : {}
171
207
  };
172
208
  }
173
209
  return {
@@ -181,15 +217,17 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
181
217
  }
182
218
  };
183
219
  } catch (err) {
184
- if (attempt === 3) throw err;
220
+ if (attempt === 3) {
221
+ return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
222
+ }
185
223
  const delayMs = 1e3 * 2 ** (attempt - 1);
186
224
  await new Promise((resolve) => setTimeout(resolve, delayMs));
187
225
  }
188
226
  }
189
- return lastRateLimitError || { success: false, error: "Request failed after retries" };
227
+ return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
190
228
  }
191
- async function callAI(systemPrompt, userPrompt, tier = "free") {
192
- if (!API_KEY) {
229
+ async function callAI(systemPrompt, userPrompt, tier = "free", opts = {}) {
230
+ if (!apiKey()) {
193
231
  return {
194
232
  success: false,
195
233
  error: "AI service is not configured. Please report this issue.",
@@ -198,7 +236,7 @@ async function callAI(systemPrompt, userPrompt, tier = "free") {
198
236
  }
199
237
  try {
200
238
  const token = await getOrRefreshDirectToken();
201
- return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier);
239
+ return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier, opts);
202
240
  } catch (err) {
203
241
  return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
204
242
  }
@@ -329,6 +367,9 @@ function shouldSkipLink(text, href, currentPath) {
329
367
  }
330
368
 
331
369
  // src/lib/crawl.ts
370
+ function idSelector(id) {
371
+ return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
372
+ }
332
373
  function getActionForType(type) {
333
374
  switch (type) {
334
375
  case "button":
@@ -351,20 +392,29 @@ var noopTask = () => ({ succeed: () => {
351
392
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
352
393
  var CRAWL_CACHE_MAX_ENTRIES = 50;
353
394
  var crawlCache = /* @__PURE__ */ new Map();
354
- async function crawlPage(url, task = noopTask, storageStatePath) {
355
- const cached = crawlCache.get(url);
395
+ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
396
+ const cacheKey = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
397
+ const cached = crawlCache.get(cacheKey);
356
398
  if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
357
399
  task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
358
- return { title: cached.title, elements: [...cached.elements] };
400
+ return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
359
401
  }
360
402
  const browserTask = task("Launching browser");
361
403
  const browser = await chromium.launch({ headless: true });
362
- const context = await browser.newContext(storageStatePath ? { storageState: storageStatePath } : {});
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
+ });
363
410
  const page = await context.newPage();
364
411
  browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
365
412
  const crawlTask = task("Crawling page");
366
413
  await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
367
414
  const title = await page.title();
415
+ const scrollable = await page.evaluate(
416
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
417
+ );
368
418
  const rawElements = await page.evaluate(`
369
419
  (() => {
370
420
  const items = [];
@@ -454,9 +504,9 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
454
504
  } else if (el.isFile || el.type === "file") {
455
505
  selector = 'input[type="file"]';
456
506
  } else if (el.type === "range") {
457
- selector = el.id ? `#${el.id}` : 'input[type="range"]';
507
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
458
508
  } else if (el.type === "checkbox") {
459
- selector = el.id ? `#${el.id}` : 'input[type="checkbox"]';
509
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
460
510
  } else if (el.type === "button") {
461
511
  if (btnTexts[el.text] === 1) {
462
512
  selector = `text="${el.text}"`;
@@ -466,7 +516,7 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
466
516
  } else if (el.type === "link") {
467
517
  selector = `text="${el.text}"`;
468
518
  } else if (el.id) {
469
- selector = `#${el.id}`;
519
+ selector = idSelector(el.id);
470
520
  } else if (el.name) {
471
521
  selector = `[name="${el.name}"]`;
472
522
  } else if (el.placeholder) {
@@ -494,12 +544,12 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
494
544
  });
495
545
  await browser.close();
496
546
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
497
- crawlCache.set(url, { timestamp: Date.now(), title, elements: [...elements] });
547
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
498
548
  if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
499
549
  const oldest = crawlCache.keys().next().value;
500
550
  if (oldest !== void 0) crawlCache.delete(oldest);
501
551
  }
502
- return { title, elements };
552
+ return { title, elements, scrollable };
503
553
  }
504
554
 
505
555
  // src/lib/plan-validation.ts
@@ -733,6 +783,80 @@ function restorePreservedValues(step, preserved, indices) {
733
783
  return { uploadIdx, mediaIdx };
734
784
  }
735
785
 
786
+ // src/lib/plan-file.ts
787
+ var MAX_HANDOFF_URL_CHARS = 32e3;
788
+ function serializePlan(plan) {
789
+ return `${JSON.stringify(plan, null, 2)}
790
+ `;
791
+ }
792
+ function parsePlanText(text) {
793
+ let raw;
794
+ try {
795
+ raw = JSON.parse(text);
796
+ } catch (e) {
797
+ return { ok: false, error: `Not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
798
+ }
799
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
800
+ return { ok: false, error: "A plan must be a JSON object." };
801
+ }
802
+ const candidate = raw;
803
+ if (typeof candidate.url !== "string" || !candidate.url.trim()) {
804
+ return { ok: false, error: "A plan needs a `url` to record." };
805
+ }
806
+ if (!Array.isArray(candidate.steps) || candidate.steps.length === 0) {
807
+ return { ok: false, error: "A plan needs at least one step." };
808
+ }
809
+ const steps = [];
810
+ for (const [index, step] of candidate.steps.entries()) {
811
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
812
+ return { ok: false, error: `Step ${index + 1} is not an object.` };
813
+ }
814
+ const typed = step;
815
+ if (typeof typed.action !== "string" || !typed.action.trim()) {
816
+ return { ok: false, error: `Step ${index + 1} has no action.` };
817
+ }
818
+ const failure = validateStep(typed);
819
+ if (failure) return { ok: false, error: `Step ${index + 1}: ${failure}` };
820
+ steps.push({ ...typed, id: typed.id ?? index + 1 });
821
+ }
822
+ return {
823
+ ok: true,
824
+ plan: {
825
+ url: candidate.url,
826
+ title: candidate.title ?? "",
827
+ description: candidate.description ?? "",
828
+ ...candidate.lang ? { lang: candidate.lang } : {},
829
+ steps
830
+ }
831
+ };
832
+ }
833
+ function toBase64Url(text) {
834
+ const bytes = new TextEncoder().encode(text);
835
+ const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
836
+ const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(text, "utf8").toString("base64");
837
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
838
+ }
839
+ function fromBase64Url(encoded) {
840
+ const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
841
+ if (typeof atob !== "function") return Buffer.from(base64, "base64").toString("utf8");
842
+ const binary = atob(base64);
843
+ return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
844
+ }
845
+ function encodePlan(plan) {
846
+ return toBase64Url(serializePlan(plan));
847
+ }
848
+ function decodePlan(encoded) {
849
+ try {
850
+ return parsePlanText(fromBase64Url(encoded));
851
+ } catch {
852
+ return { ok: false, error: "That link is damaged \u2014 the plan could not be decoded." };
853
+ }
854
+ }
855
+ function planEditorUrl(baseUrl, plan) {
856
+ const url = `${baseUrl.replace(/\/$/, "")}/edit#plan=${encodePlan(plan)}`;
857
+ return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
858
+ }
859
+
736
860
  // src/lib/prompts.ts
737
861
  function buildSystemPrompt(maxSteps) {
738
862
  let scope;
@@ -786,7 +910,8 @@ EXAMPLES:
786
910
  \u2022 File upload: "target": "input[type=\\"file\\"]"
787
911
  \u2022 Slider: "target": "input[type=\\"range\\"]"`;
788
912
  }
789
- function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
913
+ var MIN_USEFUL_SCROLL_PX = 200;
914
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable) {
790
915
  const lang = langName ? `
791
916
  Language: Write ALL captions in ${langName}.` : "";
792
917
  const purpose = description ? `
@@ -824,8 +949,13 @@ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`
824
949
  ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
825
950
  }
826
951
  const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
952
+ const cannotScroll = scrollable !== void 0 && scrollable < MIN_USEFUL_SCROLL_PX;
953
+ const scrolling = cannotScroll ? `
954
+
955
+ SCROLLING: this page has only ${scrollable}px of scroll travel at the recording size, so it does not scroll.` : "";
956
+ const scrollRule = cannotScroll ? "\n\u2022 NEVER use the scroll action: this page does not scroll, so the video would freeze. Use wait and click steps only" : "";
827
957
  return `URL: ${url}
828
- Title: ${title}${purpose}
958
+ Title: ${title}${purpose}${scrolling}
829
959
 
830
960
  AVAILABLE ELEMENTS (grouped by action):
831
961
  ${elementSummary}
@@ -835,7 +965,7 @@ REQUIREMENTS:
835
965
  \u2022 Complete ONE user journey: input \u2192 process \u2192 output
836
966
  \u2022 For CLICK: use simplified text target like "extract frame" or "download"
837
967
  \u2022 For UPLOAD/DRAG/TYPE: use the CSS selector shown above
838
- \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${lang}
968
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${scrollRule}${lang}
839
969
 
840
970
  Return JSON:
841
971
  {
@@ -861,7 +991,8 @@ TARGET SYNTAX:
861
991
 
862
992
  // src/lib/SparkStripe-feature-gating.ts
863
993
  var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
864
- var BASE = "https://sparkstripe.com";
994
+ var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
995
+ var BASE = "https://sparkpay.dev";
865
996
  var TIERS = {
866
997
  price_1TADnPEf9opkuWid9NtqOYON: "pro",
867
998
  price_1TADnPEf9opkuWidgbvvX9ma: "pro",
@@ -904,7 +1035,7 @@ async function getStatusCached(email) {
904
1035
  function getTier(d) {
905
1036
  if (!d.subscription || !d.registered) return "free";
906
1037
  if (d.subscription.status === "canceled" || d.subscription.status === "past_due") return "free";
907
- return TIERS[d.subscription.price_id] ?? "free";
1038
+ return TIERS[d.subscription.price_id] ?? "pro";
908
1039
  }
909
1040
  var isPaid = (d) => getTier(d) !== "free";
910
1041
  var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
@@ -926,7 +1057,7 @@ async function isVerified(email) {
926
1057
  }
927
1058
  }
928
1059
  function pricingUrl(opts) {
929
- const u = new URL(`${BASE}/pricing/${APP_ID}`);
1060
+ const u = new URL(`${BASE}/${OWNER_SLUG}/${APP_ID}`);
930
1061
  if (opts?.email) u.searchParams.set("email", opts.email);
931
1062
  if (opts?.returnUrl) u.searchParams.set("return_url", opts.returnUrl);
932
1063
  return u.toString();
@@ -942,12 +1073,15 @@ export {
942
1073
  INTERACTIVE_SELECTORS,
943
1074
  INVALID_TARGETS,
944
1075
  WIDE_VOICES as LANGUAGE_VOICES,
1076
+ MAX_HANDOFF_URL_CHARS,
945
1077
  REFINE_PLAN_SYSTEM_PROMPT,
946
1078
  buildSystemPrompt,
947
1079
  buildUserPrompt,
948
1080
  callAI,
949
1081
  collectPreservedValues,
950
1082
  crawlPage,
1083
+ decodePlan,
1084
+ encodePlan,
951
1085
  filterValidSteps,
952
1086
  getErrorMessage,
953
1087
  getLanguageName,
@@ -967,9 +1101,12 @@ export {
967
1101
  normalizeUrl,
968
1102
  parseAIPlanResponse,
969
1103
  parsePartial,
1104
+ parsePlanText,
1105
+ planEditorUrl,
970
1106
  pricingUrl,
971
1107
  restorePreservedValues,
972
1108
  rgbaToHex,
1109
+ serializePlan,
973
1110
  shouldSkipLink,
974
1111
  showPaywall,
975
1112
  toTitleCase,
package/mcp.mjs CHANGED
@@ -17,7 +17,7 @@ import { readFileSync } from 'node:fs';
17
17
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
18
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
19
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
20
- import { TOOL_HANDLERS, TOOLS } from './dist/mcp-tools.mjs';
20
+ import { setCanElicit, setElicit, TOOL_HANDLERS, TOOLS } from './dist/mcp-tools.mjs';
21
21
 
22
22
  const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
23
23
 
@@ -67,6 +67,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
67
67
  async function main() {
68
68
  const transport = new StdioServerTransport(process.stdin, protocolStdout);
69
69
  await server.connect(transport);
70
+
71
+ // Elicitation is optional in MCP. The capability is checked per call, not
72
+ // here: the client only declares it in `initialize`, which arrives after
73
+ // connect() resolves. null means "cannot prompt" and start_auth then falls
74
+ // back to a follow-up finish_auth call.
75
+ setElicit(async (message) => {
76
+ if (!server.getClientCapabilities()?.elicitation) return null;
77
+ const result = await server.elicitInput({
78
+ message,
79
+ requestedSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ signedIn: {
83
+ type: 'boolean',
84
+ title: 'Signed in?',
85
+ description: 'Confirm once you have finished signing in, so the session can be saved.',
86
+ },
87
+ },
88
+ required: ['signedIn'],
89
+ },
90
+ });
91
+ return result.action === 'accept' && result.content?.signedIn === true;
92
+ });
93
+
94
+ // Read per call for the same reason as above: `initialize` has not arrived
95
+ // yet. Lets tools skip opening a sign-in window no prompt could ever confirm.
96
+ setCanElicit(() => !!server.getClientCapabilities()?.elicitation);
70
97
  }
71
98
 
72
99
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spark-apps/quickpeek",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Auto-generate demo videos for your web app with AI",
5
5
  "main": "dist/qp.js",
6
6
  "bin": {