imprint-mcp 0.4.2 → 0.4.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imprint-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -75,6 +75,8 @@ interface LadderResult {
|
|
|
75
75
|
const log = createLog('backend');
|
|
76
76
|
|
|
77
77
|
const DEFAULT_LADDER: ConcreteBackend[] = ['fetch', 'stealth-fetch', 'playbook'];
|
|
78
|
+
const DEFAULT_PLAYBOOK_BACKEND_TIMEOUT_MS = 75_000;
|
|
79
|
+
const DEFAULT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS = 20_000;
|
|
78
80
|
|
|
79
81
|
/** Process-scoped memo of the backend that last succeeded for a site on the
|
|
80
82
|
* compile/test path (`runWorkflowWithLadder`). Lets the param-coverage suite
|
|
@@ -182,6 +184,22 @@ function sleepMs(ms: number): Promise<void> {
|
|
|
182
184
|
return new Promise((r) => setTimeout(r, ms));
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
function playbookBackendTimeoutMs(): number {
|
|
188
|
+
return positiveEnvMs('IMPRINT_PLAYBOOK_BACKEND_TIMEOUT_MS', DEFAULT_PLAYBOOK_BACKEND_TIMEOUT_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function playbookBackendStepTimeoutMs(): number {
|
|
192
|
+
return positiveEnvMs(
|
|
193
|
+
'IMPRINT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS',
|
|
194
|
+
DEFAULT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function positiveEnvMs(name: string, fallback: number): number {
|
|
199
|
+
const raw = Number(process.env[name] ?? fallback);
|
|
200
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
function withWorkflowDefaults(
|
|
186
204
|
workflow: Workflow,
|
|
187
205
|
params: Record<string, string | number | boolean>,
|
|
@@ -340,6 +358,8 @@ export async function runWithLadder(
|
|
|
340
358
|
playbook: playbookPath(assetRoot, tool.site, tool.dir),
|
|
341
359
|
params: paramsWithDefaults,
|
|
342
360
|
site: tool.site,
|
|
361
|
+
stepTimeoutMs: playbookBackendStepTimeoutMs(),
|
|
362
|
+
maxDurationMs: playbookBackendTimeoutMs(),
|
|
343
363
|
});
|
|
344
364
|
break;
|
|
345
365
|
}
|
|
@@ -1466,7 +1486,7 @@ export async function runWorkflowWithLadder(opts: {
|
|
|
1466
1486
|
// A backend that finishes AFTER the probe returned (it lost the race but
|
|
1467
1487
|
// is still cold-starting Chrome) pools its browser late — arm the idle
|
|
1468
1488
|
// close so it's torn down rather than left lingering.
|
|
1469
|
-
void inner.finally(() => armCompileCdpIdleClose());
|
|
1489
|
+
void inner.finally(() => armCompileCdpIdleClose()).catch(() => {});
|
|
1470
1490
|
const r = await Promise.race([
|
|
1471
1491
|
inner,
|
|
1472
1492
|
sleepMs(PROBE_TIMEOUT_MS).then(
|
|
@@ -30,6 +30,10 @@ interface RunPlaybookOptions {
|
|
|
30
30
|
headed?: boolean;
|
|
31
31
|
/** Per-step timeout in ms. Default 30000. */
|
|
32
32
|
stepTimeoutMs?: number;
|
|
33
|
+
/** Whole-playbook timeout in ms. Default unbounded for direct playbook runs. */
|
|
34
|
+
maxDurationMs?: number;
|
|
35
|
+
/** Timeout for diagnostic screenshots in ms. Default 5000. */
|
|
36
|
+
screenshotTimeoutMs?: number;
|
|
33
37
|
/** Screenshot after every step (not just on failure). */
|
|
34
38
|
trace?: boolean;
|
|
35
39
|
/** Inject a Playwright Page for tests. */
|
|
@@ -44,6 +48,8 @@ interface RunPlaybookOptions {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
const log = createLog('playbook');
|
|
51
|
+
const DEFAULT_STEP_TIMEOUT_MS = 30000;
|
|
52
|
+
const DEFAULT_SCREENSHOT_TIMEOUT_MS = 5000;
|
|
47
53
|
|
|
48
54
|
export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult> {
|
|
49
55
|
let playbook: Playbook;
|
|
@@ -57,7 +63,10 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
57
63
|
// Generous default — Akamai sensor JS, A/B loaders, lazy bundles all
|
|
58
64
|
// need real time to settle. Tight timeouts make broken sites look
|
|
59
65
|
// worse than they are.
|
|
60
|
-
const stepTimeoutMs = opts.stepTimeoutMs
|
|
66
|
+
const stepTimeoutMs = positiveMs(opts.stepTimeoutMs, DEFAULT_STEP_TIMEOUT_MS);
|
|
67
|
+
const screenshotTimeoutMs = positiveMs(opts.screenshotTimeoutMs, DEFAULT_SCREENSHOT_TIMEOUT_MS);
|
|
68
|
+
const deadlineAt =
|
|
69
|
+
opts.maxDurationMs !== undefined ? Date.now() + positiveMs(opts.maxDurationMs, 1) : null;
|
|
61
70
|
|
|
62
71
|
let browser: Browser | undefined;
|
|
63
72
|
let context: BrowserContext | undefined;
|
|
@@ -137,19 +146,42 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
137
146
|
|
|
138
147
|
for (const [i, step] of playbook.steps.entries()) {
|
|
139
148
|
lastStep = i + 1;
|
|
149
|
+
const budgetMs = budgetedTimeoutMs(
|
|
150
|
+
stepTimeoutMs,
|
|
151
|
+
deadlineAt,
|
|
152
|
+
`Playbook exceeded max duration before step ${lastStep}`,
|
|
153
|
+
);
|
|
140
154
|
log(`step ${i + 1}/${playbook.steps.length}: ${step.action}`);
|
|
141
|
-
await
|
|
155
|
+
await withTimeout(
|
|
156
|
+
executeStep(page, step, params, budgetMs),
|
|
157
|
+
budgetMs,
|
|
158
|
+
`Playbook step ${lastStep}/${playbook.steps.length} (${step.action})`,
|
|
159
|
+
);
|
|
142
160
|
if (opts.trace) {
|
|
143
|
-
const traceShot = await screenshot(
|
|
161
|
+
const traceShot = await screenshot(
|
|
162
|
+
page,
|
|
163
|
+
`${playbook.toolName}-trace`,
|
|
164
|
+
lastStep,
|
|
165
|
+
screenshotTimeoutMs,
|
|
166
|
+
);
|
|
144
167
|
log(` url=${page.url()}`);
|
|
145
168
|
if (traceShot) log(` trace screenshot: ${traceShot}`);
|
|
146
169
|
}
|
|
147
170
|
}
|
|
148
|
-
|
|
171
|
+
const bodyReadBudgetMs = budgetedTimeoutMs(
|
|
172
|
+
stepTimeoutMs,
|
|
173
|
+
deadlineAt,
|
|
174
|
+
'Playbook exceeded max duration while reading captured responses',
|
|
175
|
+
);
|
|
176
|
+
await withTimeout(
|
|
177
|
+
Promise.allSettled(pendingBodyReads),
|
|
178
|
+
bodyReadBudgetMs,
|
|
179
|
+
'Playbook captured-response drain',
|
|
180
|
+
);
|
|
149
181
|
const data = await extractResult(page, playbook.result, captured);
|
|
150
182
|
return { ok: true, data };
|
|
151
183
|
} catch (err) {
|
|
152
|
-
const screenshotPath = await screenshot(page, playbook.toolName, lastStep);
|
|
184
|
+
const screenshotPath = await screenshot(page, playbook.toolName, lastStep, screenshotTimeoutMs);
|
|
153
185
|
const suffix = screenshotPath ? `\nscreenshot: ${screenshotPath}` : '';
|
|
154
186
|
const errStr = errMsg(err);
|
|
155
187
|
// Classify the failure mode honestly: a missing locator, a step
|
|
@@ -161,9 +193,10 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
161
193
|
// bug, which over-attributes drift to defects. Map known
|
|
162
194
|
// transient-shape errors to NETWORK so they count as `infra`
|
|
163
195
|
// (re-runnable) rather than `tool_broken` (permanent defect).
|
|
164
|
-
const isTransient =
|
|
165
|
-
|
|
166
|
-
|
|
196
|
+
const isTransient =
|
|
197
|
+
/No locator matched|Timeout \d+ms exceeded|timed out after|exceeded max duration|forResponse|waiting for/i.test(
|
|
198
|
+
errStr,
|
|
199
|
+
);
|
|
167
200
|
return {
|
|
168
201
|
ok: false,
|
|
169
202
|
error: isTransient ? 'NETWORK' : 'BAD_RESPONSE',
|
|
@@ -177,19 +210,58 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
177
210
|
}
|
|
178
211
|
}
|
|
179
212
|
|
|
180
|
-
async function screenshot(
|
|
213
|
+
async function screenshot(
|
|
214
|
+
page: Page,
|
|
215
|
+
toolName: string,
|
|
216
|
+
stepNum: number,
|
|
217
|
+
timeoutMs: number,
|
|
218
|
+
): Promise<string | null> {
|
|
181
219
|
try {
|
|
182
220
|
const { tmpdir } = await import('node:os');
|
|
183
221
|
const { join } = await import('node:path');
|
|
184
222
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
185
223
|
const path = join(tmpdir(), `imprint-playbook-${toolName}-step${stepNum}-${ts}.png`);
|
|
186
|
-
await page.screenshot({ path, fullPage: true });
|
|
224
|
+
await withTimeout(page.screenshot({ path, fullPage: true }), timeoutMs, 'Playbook screenshot');
|
|
187
225
|
return path;
|
|
188
226
|
} catch {
|
|
189
227
|
return null;
|
|
190
228
|
}
|
|
191
229
|
}
|
|
192
230
|
|
|
231
|
+
function positiveMs(value: number | undefined, fallback: number): number {
|
|
232
|
+
if (value === undefined) return fallback;
|
|
233
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function budgetedTimeoutMs(
|
|
237
|
+
configuredMs: number,
|
|
238
|
+
deadlineAt: number | null,
|
|
239
|
+
errorMessage: string,
|
|
240
|
+
): number {
|
|
241
|
+
if (deadlineAt === null) return configuredMs;
|
|
242
|
+
const remainingMs = deadlineAt - Date.now();
|
|
243
|
+
if (remainingMs <= 0) throw new Error(errorMessage);
|
|
244
|
+
return Math.max(1, Math.min(configuredMs, Math.floor(remainingMs)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
248
|
+
const boundedMs = positiveMs(timeoutMs, 1);
|
|
249
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
try {
|
|
251
|
+
return await Promise.race([
|
|
252
|
+
promise,
|
|
253
|
+
new Promise<never>((_resolve, reject) => {
|
|
254
|
+
timer = setTimeout(
|
|
255
|
+
() => reject(new Error(`${label} timed out after ${boundedMs}ms`)),
|
|
256
|
+
boundedMs,
|
|
257
|
+
);
|
|
258
|
+
}),
|
|
259
|
+
]);
|
|
260
|
+
} finally {
|
|
261
|
+
if (timer) clearTimeout(timer);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
193
265
|
async function loadPlaybook(input: string | Playbook): Promise<Playbook> {
|
|
194
266
|
if (typeof input !== 'string') return input;
|
|
195
267
|
if (!existsSync(input)) {
|
|
@@ -272,6 +272,27 @@ function backendResultTooSlow(result: BackendsCache['results'][string] | undefin
|
|
|
272
272
|
return result?.outcome === 'ok' && result.tooSlow === true;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
function invalidPreferredOrderReason(cache: BackendsCache): string | null {
|
|
276
|
+
for (const backend of cache.preferredOrder) {
|
|
277
|
+
const result = cache.results[backend];
|
|
278
|
+
if (backend === 'playbook' && result?.outcome !== 'ok') {
|
|
279
|
+
return 'preferredOrder includes playbook without a successful playbook result';
|
|
280
|
+
}
|
|
281
|
+
if (result && result.outcome !== 'ok') {
|
|
282
|
+
return `preferredOrder includes ${backend} with ${result.outcome} result`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function existingBackendUsable(
|
|
289
|
+
backend: ConcreteBackend,
|
|
290
|
+
result: BackendsCache['results'][string] | undefined,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (!result) return backend !== 'playbook';
|
|
293
|
+
return result.outcome === 'ok';
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
async function probeWarmCdpReplay(
|
|
276
297
|
tool: ResolvedTool,
|
|
277
298
|
params: Record<string, string | number | boolean>,
|
|
@@ -358,6 +379,15 @@ export function loadBackendsCacheStatus(
|
|
|
358
379
|
}
|
|
359
380
|
}
|
|
360
381
|
}
|
|
382
|
+
const invalidPreferredReason = invalidPreferredOrderReason(parsed);
|
|
383
|
+
if (invalidPreferredReason) {
|
|
384
|
+
if (opts.warn !== false) {
|
|
385
|
+
process.stderr.write(
|
|
386
|
+
`[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
|
|
390
|
+
}
|
|
361
391
|
return { status: 'ok', path, cache: parsed };
|
|
362
392
|
} catch (err) {
|
|
363
393
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -438,17 +468,11 @@ export function persistRuntimeBackendsCache(opts: {
|
|
|
438
468
|
const usedOkAttempt = observedOkAttempts.find((a) => a.backend === opts.usedBackend);
|
|
439
469
|
const usedBackendTooSlow =
|
|
440
470
|
usedOkAttempt !== undefined && usedOkAttempt.durationMs > preferredBackendMaxMs();
|
|
441
|
-
const
|
|
442
|
-
(backend
|
|
443
|
-
);
|
|
444
|
-
const existingSlow = existingPreferred.filter((backend) =>
|
|
445
|
-
backendResultTooSlow(results[backend]),
|
|
471
|
+
const existingUsable = existingPreferred.filter((backend) =>
|
|
472
|
+
existingBackendUsable(backend, results[backend]),
|
|
446
473
|
);
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
)
|
|
450
|
-
? ['playbook']
|
|
451
|
-
: [];
|
|
474
|
+
const existingFast = existingUsable.filter((backend) => !backendResultTooSlow(results[backend]));
|
|
475
|
+
const existingSlow = existingUsable.filter((backend) => backendResultTooSlow(results[backend]));
|
|
452
476
|
const preferredOrder = uniqueBackends([
|
|
453
477
|
...(usedOkAttempt && !usedBackendTooSlow ? [opts.usedBackend] : []),
|
|
454
478
|
...existingFast,
|
|
@@ -456,7 +480,6 @@ export function persistRuntimeBackendsCache(opts: {
|
|
|
456
480
|
...existingSlow,
|
|
457
481
|
...slowObservedOk,
|
|
458
482
|
...(usedOkAttempt && usedBackendTooSlow ? [opts.usedBackend] : []),
|
|
459
|
-
...structuralFallbacks,
|
|
460
483
|
]);
|
|
461
484
|
const cache: BackendsCache = {
|
|
462
485
|
probedAt: new Date().toISOString(),
|