@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.
- package/README.md +204 -204
- package/dist/highlight.css +15 -15
- package/dist/index.d.mts +79 -3
- package/dist/index.mjs +284 -65
- package/dist/mcp-tools.mjs +1861 -872
- package/dist/qp.js +1606 -838
- package/dist/{scraping-BK8pIJhm.d.mts → scraping-BgepklP3.d.mts} +51 -6
- package/dist/web.d.mts +42 -2
- package/dist/web.mjs +174 -37
- package/mcp.mjs +28 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration types and defaults for QuickPeek
|
|
3
3
|
*/
|
|
4
|
-
declare const VERSION = "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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
try {
|
|
163
|
+
data = bodyText ? JSON.parse(bodyText) : {};
|
|
164
|
+
} catch {
|
|
145
165
|
}
|
|
146
166
|
if (!response.ok) {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
155
|
-
|
|
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 || "
|
|
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)
|
|
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
|
|
227
|
+
return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
|
|
190
228
|
}
|
|
191
|
-
async function callAI(systemPrompt, userPrompt, tier = "free") {
|
|
192
|
-
if (!
|
|
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
|
|
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(
|
|
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 ?
|
|
507
|
+
selector = el.id ? idSelector(el.id) : 'input[type="range"]';
|
|
458
508
|
} else if (el.type === "checkbox") {
|
|
459
|
-
selector = el.id ?
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
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] ?? "
|
|
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}
|
|
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) => {
|