@spark-apps/quickpeek 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -187,9 +187,13 @@ interface Plan {
187
187
  */
188
188
  declare function crawlPage(url: string, task?: (text: string) => {
189
189
  succeed: (text?: string) => void;
190
- }, storageStatePath?: string): Promise<{
190
+ }, storageStatePath?: string, viewport?: {
191
+ width: number;
192
+ height: number;
193
+ }): Promise<{
191
194
  title: string;
192
195
  elements: ElementInfo[];
196
+ scrollable: number;
193
197
  }>;
194
198
 
195
199
  /**
@@ -229,7 +233,7 @@ declare function toTitleCase(text: string): string;
229
233
  */
230
234
 
231
235
  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;
236
+ declare function buildUserPrompt(url: string, title: string, elements: ElementInfo[], langName: string | null, description: string | undefined, maxSteps: number, scrollable?: number): string;
233
237
 
234
238
  type Tier = 'free' | 'pro';
235
239
  declare const FEATURES: Record<string, string[]>;
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-CHN4ClyT.mjs';
2
+ 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, 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-CHN4ClyT.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
  }
@@ -93,7 +93,9 @@ var SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
93
93
  var RELAY_BASE = process.env.QUICKPEEK_API_BASE || "https://quickpeek.co";
94
94
  var RELAY_TOKEN_URL = `${RELAY_BASE}/api/cli/token`;
95
95
  var RELAY_CHAT_URL = `${RELAY_BASE}/api/cli/ai`;
96
- var API_KEY = process.env.SPARK_AI_KEY || "";
96
+ function apiKey() {
97
+ return process.env.SPARK_AI_KEY || "";
98
+ }
97
99
  var cachedDirectToken = null;
98
100
  async function getOrRefreshDirectToken() {
99
101
  const now = Date.now();
@@ -103,7 +105,7 @@ async function getOrRefreshDirectToken() {
103
105
  try {
104
106
  const response = await fetch(SPARK_AI_TOKEN, {
105
107
  method: "POST",
106
- headers: { "X-Api-Key": API_KEY }
108
+ headers: { "X-Api-Key": apiKey() }
107
109
  });
108
110
  if (!response.ok) {
109
111
  throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
@@ -189,7 +191,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
189
191
  return lastRateLimitError || { success: false, error: "Request failed after retries" };
190
192
  }
191
193
  async function callAI(systemPrompt, userPrompt, tier = "free") {
192
- if (!API_KEY) {
194
+ if (!apiKey()) {
193
195
  return {
194
196
  success: false,
195
197
  error: "AI service is not configured. Please report this issue.",
@@ -351,20 +353,29 @@ var noopTask = () => ({ succeed: () => {
351
353
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
352
354
  var CRAWL_CACHE_MAX_ENTRIES = 50;
353
355
  var crawlCache = /* @__PURE__ */ new Map();
354
- async function crawlPage(url, task = noopTask, storageStatePath) {
355
- const cached = crawlCache.get(url);
356
+ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
357
+ const cacheKey = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
358
+ const cached = crawlCache.get(cacheKey);
356
359
  if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
357
360
  task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
358
- return { title: cached.title, elements: [...cached.elements] };
361
+ return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
359
362
  }
360
363
  const browserTask = task("Launching browser");
361
364
  const browser = await chromium.launch({ headless: true });
362
- const context = await browser.newContext(storageStatePath ? { storageState: storageStatePath } : {});
365
+ const context = await browser.newContext({
366
+ ...storageStatePath ? { storageState: storageStatePath } : {},
367
+ // Crawl at the size we will record at, or the measurements below describe
368
+ // a page nobody will ever see.
369
+ ...viewport ? { viewport } : {}
370
+ });
363
371
  const page = await context.newPage();
364
372
  browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
365
373
  const crawlTask = task("Crawling page");
366
374
  await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
367
375
  const title = await page.title();
376
+ const scrollable = await page.evaluate(
377
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
378
+ );
368
379
  const rawElements = await page.evaluate(`
369
380
  (() => {
370
381
  const items = [];
@@ -494,12 +505,12 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
494
505
  });
495
506
  await browser.close();
496
507
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
497
- crawlCache.set(url, { timestamp: Date.now(), title, elements: [...elements] });
508
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
498
509
  if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
499
510
  const oldest = crawlCache.keys().next().value;
500
511
  if (oldest !== void 0) crawlCache.delete(oldest);
501
512
  }
502
- return { title, elements };
513
+ return { title, elements, scrollable };
503
514
  }
504
515
 
505
516
  // src/lib/plan-validation.ts
@@ -733,6 +744,80 @@ function restorePreservedValues(step, preserved, indices) {
733
744
  return { uploadIdx, mediaIdx };
734
745
  }
735
746
 
747
+ // src/lib/plan-file.ts
748
+ var MAX_HANDOFF_URL_CHARS = 32e3;
749
+ function serializePlan(plan) {
750
+ return `${JSON.stringify(plan, null, 2)}
751
+ `;
752
+ }
753
+ function parsePlanText(text) {
754
+ let raw;
755
+ try {
756
+ raw = JSON.parse(text);
757
+ } catch (e) {
758
+ return { ok: false, error: `Not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
759
+ }
760
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
761
+ return { ok: false, error: "A plan must be a JSON object." };
762
+ }
763
+ const candidate = raw;
764
+ if (typeof candidate.url !== "string" || !candidate.url.trim()) {
765
+ return { ok: false, error: "A plan needs a `url` to record." };
766
+ }
767
+ if (!Array.isArray(candidate.steps) || candidate.steps.length === 0) {
768
+ return { ok: false, error: "A plan needs at least one step." };
769
+ }
770
+ const steps = [];
771
+ for (const [index, step] of candidate.steps.entries()) {
772
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
773
+ return { ok: false, error: `Step ${index + 1} is not an object.` };
774
+ }
775
+ const typed = step;
776
+ if (typeof typed.action !== "string" || !typed.action.trim()) {
777
+ return { ok: false, error: `Step ${index + 1} has no action.` };
778
+ }
779
+ const failure = validateStep(typed);
780
+ if (failure) return { ok: false, error: `Step ${index + 1}: ${failure}` };
781
+ steps.push({ ...typed, id: typed.id ?? index + 1 });
782
+ }
783
+ return {
784
+ ok: true,
785
+ plan: {
786
+ url: candidate.url,
787
+ title: candidate.title ?? "",
788
+ description: candidate.description ?? "",
789
+ ...candidate.lang ? { lang: candidate.lang } : {},
790
+ steps
791
+ }
792
+ };
793
+ }
794
+ function toBase64Url(text) {
795
+ const bytes = new TextEncoder().encode(text);
796
+ const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
797
+ const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(text, "utf8").toString("base64");
798
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
799
+ }
800
+ function fromBase64Url(encoded) {
801
+ const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
802
+ if (typeof atob !== "function") return Buffer.from(base64, "base64").toString("utf8");
803
+ const binary = atob(base64);
804
+ return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
805
+ }
806
+ function encodePlan(plan) {
807
+ return toBase64Url(serializePlan(plan));
808
+ }
809
+ function decodePlan(encoded) {
810
+ try {
811
+ return parsePlanText(fromBase64Url(encoded));
812
+ } catch {
813
+ return { ok: false, error: "That link is damaged \u2014 the plan could not be decoded." };
814
+ }
815
+ }
816
+ function planEditorUrl(baseUrl, plan) {
817
+ const url = `${baseUrl.replace(/\/$/, "")}/edit#plan=${encodePlan(plan)}`;
818
+ return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
819
+ }
820
+
736
821
  // src/lib/prompts.ts
737
822
  function buildSystemPrompt(maxSteps) {
738
823
  let scope;
@@ -786,7 +871,8 @@ EXAMPLES:
786
871
  \u2022 File upload: "target": "input[type=\\"file\\"]"
787
872
  \u2022 Slider: "target": "input[type=\\"range\\"]"`;
788
873
  }
789
- function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
874
+ var MIN_USEFUL_SCROLL_PX = 200;
875
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable) {
790
876
  const lang = langName ? `
791
877
  Language: Write ALL captions in ${langName}.` : "";
792
878
  const purpose = description ? `
@@ -824,8 +910,13 @@ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`
824
910
  ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
825
911
  }
826
912
  const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
913
+ const cannotScroll = scrollable !== void 0 && scrollable < MIN_USEFUL_SCROLL_PX;
914
+ const scrolling = cannotScroll ? `
915
+
916
+ SCROLLING: this page has only ${scrollable}px of scroll travel at the recording size, so it does not scroll.` : "";
917
+ 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
918
  return `URL: ${url}
828
- Title: ${title}${purpose}
919
+ Title: ${title}${purpose}${scrolling}
829
920
 
830
921
  AVAILABLE ELEMENTS (grouped by action):
831
922
  ${elementSummary}
@@ -835,7 +926,7 @@ REQUIREMENTS:
835
926
  \u2022 Complete ONE user journey: input \u2192 process \u2192 output
836
927
  \u2022 For CLICK: use simplified text target like "extract frame" or "download"
837
928
  \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}
929
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${scrollRule}${lang}
839
930
 
840
931
  Return JSON:
841
932
  {
@@ -942,12 +1033,15 @@ export {
942
1033
  INTERACTIVE_SELECTORS,
943
1034
  INVALID_TARGETS,
944
1035
  WIDE_VOICES as LANGUAGE_VOICES,
1036
+ MAX_HANDOFF_URL_CHARS,
945
1037
  REFINE_PLAN_SYSTEM_PROMPT,
946
1038
  buildSystemPrompt,
947
1039
  buildUserPrompt,
948
1040
  callAI,
949
1041
  collectPreservedValues,
950
1042
  crawlPage,
1043
+ decodePlan,
1044
+ encodePlan,
951
1045
  filterValidSteps,
952
1046
  getErrorMessage,
953
1047
  getLanguageName,
@@ -967,9 +1061,12 @@ export {
967
1061
  normalizeUrl,
968
1062
  parseAIPlanResponse,
969
1063
  parsePartial,
1064
+ parsePlanText,
1065
+ planEditorUrl,
970
1066
  pricingUrl,
971
1067
  restorePreservedValues,
972
1068
  rgbaToHex,
1069
+ serializePlan,
973
1070
  shouldSkipLink,
974
1071
  showPaywall,
975
1072
  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 { 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,29 @@ 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
+ });
70
93
  }
71
94
 
72
95
  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.1",
4
4
  "description": "Auto-generate demo videos for your web app with AI",
5
5
  "main": "dist/qp.js",
6
6
  "bin": {