autokap 1.9.7 → 1.9.9

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.
@@ -48,6 +48,12 @@ export const CHROMIUM_ARGS = [
48
48
  const MAX_CONCURRENT = parseInt(process.env.BROWSER_POOL_MAX_CONCURRENT ?? '5', 10);
49
49
  /** Restart the shared browser process after this many captures to prevent memory leaks. */
50
50
  const MAX_CAPTURES_BEFORE_RESTART = 100;
51
+ /**
52
+ * Default per-action Playwright timeout for pooled contexts. Below the 30s
53
+ * CAPTURE_SCREENSHOT global deadline so a starved call fails with budget left
54
+ * for recovery instead of eating the whole variant. See usage in acquireContext.
55
+ */
56
+ const POOL_CONTEXT_ACTION_TIMEOUT_MS = 25000;
51
57
  function normalizeDeviceScaleFactor(value) {
52
58
  if (!Number.isFinite(value))
53
59
  return 2;
@@ -79,6 +85,12 @@ class BrowserPool {
79
85
  // any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
80
86
  extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
81
87
  });
88
+ // Stop every Playwright call from inheriting the built-in 30 000 ms default.
89
+ // Pooled captures run many variants per Cloud Run container; under CPU
90
+ // contention a starved call (e.g. page.screenshot) would otherwise block the
91
+ // full 30s — equal to the CAPTURE_SCREENSHOT global deadline — leaving no
92
+ // budget for recovery and voiding the variant. 25s sits under that cap.
93
+ context.setDefaultTimeout(POOL_CONTEXT_ACTION_TIMEOUT_MS);
82
94
  // Block third-party analytics so pooled (server-side) captures don't
83
95
  // register a phantom visit in the captured site's analytics (AUT-234).
84
96
  // Skippable per-project via blockAnalytics === false.
package/dist/browser.js CHANGED
@@ -209,6 +209,29 @@ async function seedCloudChromiumPreferences(userDataDir) {
209
209
  };
210
210
  await writeFile(preferencesPath, `${JSON.stringify(preferences)}\n`, 'utf8');
211
211
  }
212
+ /**
213
+ * Default per-action timeout applied to every Playwright context we create.
214
+ * Without this, calls (page.screenshot / page.title / locator actions) inherit
215
+ * Playwright's built-in 30 000 ms default. Under Cloud Run CPU contention (many
216
+ * variants per container) a single starved call would then block for the full
217
+ * 30s — which equals the CAPTURE_SCREENSHOT global deadline, leaving zero budget
218
+ * for recovery/recapture and voiding the whole variant. 25s sits just under that
219
+ * global cap so a hung call fails with budget left for a clean, bounded error.
220
+ * NAVIGATE keeps its own bounded path — we intentionally do NOT touch
221
+ * navigationTimeout here.
222
+ */
223
+ const CONTEXT_ACTION_TIMEOUT_MS = 25000;
224
+ /**
225
+ * Explicit ceiling for the heavy screenshot primitives. Below the context
226
+ * default so an already-abandoned capture (the runner gives up earlier, ~12s)
227
+ * stops burning renderer CPU quickly instead of contending with sibling
228
+ * variants until the 25s default fires.
229
+ */
230
+ const SCREENSHOT_PRIMITIVE_TIMEOUT_MS = 20000;
231
+ /** Apply our engine-wide Playwright defaults to a freshly created context. */
232
+ function applyContextDefaults(context) {
233
+ context.setDefaultTimeout(CONTEXT_ACTION_TIMEOUT_MS);
234
+ }
212
235
  async function withHelperTimeout(label, timeoutMs, work) {
213
236
  if (!timeoutMs || timeoutMs <= 0) {
214
237
  return work();
@@ -1085,6 +1108,7 @@ export class Browser {
1085
1108
  headless: false,
1086
1109
  args: clipArgs,
1087
1110
  });
1111
+ applyContextDefaults(instance.context);
1088
1112
  instance.browser = instance.context.browser();
1089
1113
  instance.persistentContext = true;
1090
1114
  // launchPersistentContext opens an initial about:blank page automatically.
@@ -1110,6 +1134,7 @@ export class Browser {
1110
1134
  args: clipArgs,
1111
1135
  });
1112
1136
  instance.context = await instance.browser.newContext(contextOptions);
1137
+ applyContextDefaults(instance.context);
1113
1138
  }
1114
1139
  // Block third-party analytics beacons so clip/video capture doesn't
1115
1140
  // register a phantom visit either (AUT-234). Context-level so it covers
@@ -1253,6 +1278,7 @@ export class Browser {
1253
1278
  args: CHROMIUM_ARGS,
1254
1279
  });
1255
1280
  this.context = await this.browser.newContext(this.buildContextOptions());
1281
+ applyContextDefaults(this.context);
1256
1282
  if (this.options.blockAnalytics !== false) {
1257
1283
  await installAnalyticsBlock(this.context);
1258
1284
  }
@@ -1345,6 +1371,7 @@ export class Browser {
1345
1371
  this.context = null;
1346
1372
  }
1347
1373
  this.context = await this.browser.newContext(this.buildContextOptions());
1374
+ applyContextDefaults(this.context);
1348
1375
  if (this.options.blockAnalytics !== false) {
1349
1376
  await installAnalyticsBlock(this.context);
1350
1377
  }
@@ -1840,7 +1867,7 @@ export class Browser {
1840
1867
  // terminal block where ANSI escape literals were producing tofu boxes).
1841
1868
  await stripInvisibleControlChars(page);
1842
1869
  await logFontDiagnostics(page, 'before screenshot');
1843
- const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
1870
+ const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
1844
1871
  await logCaptureRenderDiagnostics(page, screenshot, this.options, 'after screenshot');
1845
1872
  return screenshot;
1846
1873
  }
@@ -5330,7 +5357,7 @@ export class Browser {
5330
5357
  try {
5331
5358
  await this.waitForFontsBeforeScreenshot(page);
5332
5359
  await logFontDiagnostics(page, 'before region screenshot');
5333
- const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
5360
+ const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
5334
5361
  const image = sharp(fullPage);
5335
5362
  const meta = await image.metadata();
5336
5363
  const imageWidth = meta.width ?? 0;
@@ -5453,7 +5480,7 @@ export class Browser {
5453
5480
  const dpr = pageInfo.dpr;
5454
5481
  await this.waitForFontsBeforeScreenshot(page);
5455
5482
  await logFontDiagnostics(page, 'before selector screenshot');
5456
- const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
5483
+ const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
5457
5484
  const image = sharp(fullPage);
5458
5485
  const meta = await image.metadata();
5459
5486
  const imgW = meta.width ?? 0;
@@ -5517,7 +5544,7 @@ export class Browser {
5517
5544
  const dpr = pageInfo.dpr;
5518
5545
  await this.waitForFontsBeforeScreenshot(page);
5519
5546
  await logFontDiagnostics(page, 'before bounding-region screenshot');
5520
- const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
5547
+ const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
5521
5548
  const image = sharp(fullPage);
5522
5549
  const meta = await image.metadata();
5523
5550
  const imgW = meta.width ?? 0;
@@ -55,6 +55,15 @@ export class NoOpRecoveryChain {
55
55
  }
56
56
  const MIN_CLIP_FINALIZATION_TIMEOUT_MS = 30000;
57
57
  const DEFAULT_VIDEO_RECORDING_RESOLUTION = { width: 1920, height: 1080 };
58
+ /**
59
+ * Per-call bounds for the CAPTURE_SCREENSHOT action, kept well under the ~30s
60
+ * global capture deadline so a hung Playwright call fails early and leaves budget
61
+ * for recovery/recapture instead of voiding the variant. Fixed constants because
62
+ * executeOpcodeAction has no access to the deadline; 12s + 3×3s worst case still
63
+ * leaves >=12s of the 30s budget for the recovery path.
64
+ */
65
+ const CAPTURE_BUFFER_TIMEOUT_MS = 12000;
66
+ const CAPTURE_METADATA_TIMEOUT_MS = 3000;
58
67
  /**
59
68
  * The compiled per-opcode action budget. For CAPTURE_SCREENSHOT this governs
60
69
  * ONLY the deterministic capture (visual stabilize + screenshot + favicon/title
@@ -463,9 +472,12 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
463
472
  error: `recovery succeeded but produced no ${opcode.kind === 'END_CLIP' ? 'clip' : 'screenshot'} artifact: ${reason}`,
464
473
  };
465
474
  };
466
- // Track page context for circuit breaker
475
+ // Track page context for circuit breaker. Bounded + best-effort: this runs on
476
+ // EVERY opcode inside its global-deadline window, so a getCurrentUrl starved by
477
+ // CPU contention must not eat the budget the capture + recovery need (it would
478
+ // otherwise be capped only by the 25s context default).
467
479
  try {
468
- const url = await adapter.getCurrentUrl();
480
+ const url = await withTimeout(() => adapter.getCurrentUrl(), CAPTURE_METADATA_TIMEOUT_MS);
469
481
  breaker.setPage(url);
470
482
  }
471
483
  catch { /* ignore */ }
@@ -879,25 +891,43 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
879
891
  lowConfidenceReasons.push(`captured before visual stability: ${stability.reason}`);
880
892
  }
881
893
  const captureLowConfidenceReason = lowConfidenceReasons.join('; ') || undefined;
882
- const captureUrl = await adapter.getCurrentUrl();
883
- const buffer = await takeCaptureBuffer(adapter, opcode);
884
- // Extract page favicon for browser bar mockup
894
+ // Capture the screenshot buffer FIRST and bound it — this is the only
895
+ // artifact-critical call. Under CPU contention (many cloud variants per
896
+ // container) a Playwright screenshot can otherwise block the full ~30s
897
+ // global deadline, leaving zero budget for recovery/recapture and voiding
898
+ // the variant. Failing at ~12s keeps ≥12s of the 30s budget for the
899
+ // recovery path (handleFailure → recapture in `failWithRecovery`).
900
+ const buffer = await withTimeout(() => takeCaptureBuffer(adapter, opcode), CAPTURE_BUFFER_TIMEOUT_MS);
901
+ // Everything below is best-effort browser-mockup metadata. The buffer is
902
+ // already in hand, so a slow/hung metadata call must NEVER throw out of
903
+ // the action and discard a good screenshot. Each is individually bounded
904
+ // and degrades gracefully (empty URL / no favicon / hostname title).
905
+ let captureUrl = '';
906
+ try {
907
+ captureUrl = await withTimeout(() => adapter.getCurrentUrl(), CAPTURE_METADATA_TIMEOUT_MS);
908
+ }
909
+ catch { /* empty captureUrl is tolerated downstream */ }
885
910
  let tabIconData;
886
911
  let tabIconMimeType;
887
912
  if (adapter.extractFavicon) {
888
- const favicon = await adapter.extractFavicon();
889
- if (favicon) {
890
- tabIconData = favicon.buffer;
891
- tabIconMimeType = favicon.mimeType;
913
+ try {
914
+ const favicon = await withTimeout(() => adapter.extractFavicon(), CAPTURE_METADATA_TIMEOUT_MS);
915
+ if (favicon) {
916
+ tabIconData = favicon.buffer;
917
+ tabIconMimeType = favicon.mimeType;
918
+ }
892
919
  }
920
+ catch { /* ship the screenshot without a favicon */ }
893
921
  }
894
- // Capture document title so browser mockups can render the real tab
895
- // title instead of falling back to the hostname.
922
+ // Real tab title for the browser mockup; falls back to hostname when null.
896
923
  let pageTitle;
897
924
  if (adapter.getPageTitle) {
898
- const resolved = await adapter.getPageTitle();
899
- if (resolved)
900
- pageTitle = resolved;
925
+ try {
926
+ const resolved = await withTimeout(() => adapter.getPageTitle(), CAPTURE_METADATA_TIMEOUT_MS);
927
+ if (resolved)
928
+ pageTitle = resolved;
929
+ }
930
+ catch { /* fall back to the hostname mockup */ }
901
931
  }
902
932
  artifacts.push({
903
933
  mediaMode: 'screenshot',
@@ -194,7 +194,15 @@ export class WebPlaywrightLocal {
194
194
  async getPageTitle() {
195
195
  const page = await this.browser.currentPage;
196
196
  try {
197
- const raw = await page.title();
197
+ // Metadata for the browser-mockup tab, not the artifact itself. page.title()
198
+ // takes no timeout and inherits Playwright's 30s default; under CPU
199
+ // contention (Cloud Run) it can swallow the whole capture action budget
200
+ // AFTER the screenshot buffer was already produced, discarding a good shot.
201
+ // Bound it tightly and degrade to the hostname fallback (null) on timeout.
202
+ const raw = await Promise.race([
203
+ page.title(),
204
+ new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
205
+ ]);
198
206
  const trimmed = raw?.trim();
199
207
  if (!trimmed)
200
208
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.9.7",
3
+ "version": "1.9.9",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,12 +0,0 @@
1
- export type SkillType = 'preset';
2
- export interface SkillPlaceholderOptions {
3
- projectUrl?: string;
4
- projectId?: string;
5
- apiBaseUrl?: string;
6
- }
7
- export declare function resolveSkillAssetDir(): Promise<string>;
8
- export declare function renderSkillSingleFile(params: {
9
- type: SkillType;
10
- agent?: string;
11
- placeholders: SkillPlaceholderOptions;
12
- }): Promise<string>;
@@ -1,110 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- function replaceSkillPlaceholders(content, opts) {
4
- let result = content;
5
- if (opts.projectUrl) {
6
- result = result.replace(/\[AUTOKAP_PROJECT_URL\]/g, opts.projectUrl);
7
- }
8
- if (opts.projectId) {
9
- result = result.replace(/\[AUTOKAP_PROJECT_ID\]/g, opts.projectId);
10
- }
11
- if (opts.apiBaseUrl) {
12
- result = result.replace(/https:\/\/autokap\.app/g, opts.apiBaseUrl);
13
- }
14
- return result;
15
- }
16
- const PRESET_SKILL_SOURCE = {
17
- coreFile: 'SKILL.md',
18
- references: [
19
- { relativePath: 'OPCODE-REFERENCE.md', title: 'Opcode Reference', anchor: 'reference-opcode-reference' },
20
- { relativePath: 'references/STANDARDS.md', title: 'Prompt Charter & Quality Standards', anchor: 'reference-prompt-standards' },
21
- { relativePath: 'references/mock-data.md', title: 'Mock Data Injection', anchor: 'reference-mock-data-injection' },
22
- { relativePath: 'references/video-workflow.md', title: 'Demo Video Workflow', anchor: 'reference-demo-video-workflow' },
23
- { relativePath: 'references/examples.md', title: 'Complete Examples', anchor: 'reference-complete-examples' },
24
- ],
25
- };
26
- function getSkillSourceSpec(type) {
27
- return PRESET_SKILL_SOURCE;
28
- }
29
- async function pathExists(candidate) {
30
- try {
31
- await fs.access(candidate);
32
- return true;
33
- }
34
- catch {
35
- return false;
36
- }
37
- }
38
- function getModuleAssetCandidates() {
39
- const url = new URL(import.meta.url);
40
- const dir = process.platform === 'win32'
41
- ? path.dirname(url.pathname.replace(/^\/([A-Za-z]:)/, '$1'))
42
- : path.dirname(url.pathname);
43
- return [
44
- path.resolve(dir, '..', 'assets', 'skill'),
45
- path.resolve(dir, '..', '..', 'assets', 'skill'),
46
- ];
47
- }
48
- export async function resolveSkillAssetDir() {
49
- const candidates = [
50
- ...getModuleAssetCandidates(),
51
- path.join(process.cwd(), 'assets', 'skill'),
52
- path.join(process.cwd(), '..', 'assets', 'skill'),
53
- path.join(process.cwd(), '..', '..', 'assets', 'skill'),
54
- ];
55
- for (const candidate of candidates) {
56
- if (await pathExists(candidate)) {
57
- return candidate;
58
- }
59
- }
60
- throw new Error('Could not find assets/skill. Make sure the autokap package is installed correctly.');
61
- }
62
- async function readSkillFile(rootDir, relativePath, placeholders) {
63
- const absolutePath = path.join(rootDir, relativePath);
64
- const raw = await fs.readFile(absolutePath, 'utf-8');
65
- return replaceSkillPlaceholders(raw, placeholders);
66
- }
67
- function insertAfterFrontmatter(content, inserted) {
68
- if (!content.startsWith('---\n')) {
69
- return `${inserted.trim()}\n\n${content}`;
70
- }
71
- const secondFence = content.indexOf('\n---\n', 4);
72
- if (secondFence === -1) {
73
- return `${inserted.trim()}\n\n${content}`;
74
- }
75
- const frontmatter = content.slice(0, secondFence + 5);
76
- const rest = content.slice(secondFence + 5).replace(/^\n+/, '');
77
- return `${frontmatter}\n${inserted.trim()}\n\n${rest}`;
78
- }
79
- function stripLeadingTitle(content) {
80
- return content.replace(/^# .*\n+/, '').trim();
81
- }
82
- function rewriteLinksForSingleFile(content, spec) {
83
- let rewritten = content.replace(/\(SKILL\.md\)/g, '(#autokap-preset-creation-skill)');
84
- for (const reference of spec.references) {
85
- const escapedPath = reference.relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
- rewritten = rewritten.replace(new RegExp(`\\(${escapedPath}\\)`, 'g'), `(#${reference.anchor})`);
87
- }
88
- return rewritten;
89
- }
90
- export async function renderSkillSingleFile(params) {
91
- const rootDir = await resolveSkillAssetDir();
92
- const spec = getSkillSourceSpec(params.type);
93
- let core = await readSkillFile(rootDir, spec.coreFile, params.placeholders);
94
- core = rewriteLinksForSingleFile(core, spec);
95
- core = insertAfterFrontmatter(core, '> Packaging note: the canonical AutoKap preset skill is a modular bundle for Codex (`SKILL.md` + companion reference files). This single-file export is generated from that same source so all agents stay in parity.');
96
- const bundledReferences = [];
97
- for (const reference of spec.references) {
98
- const content = await readSkillFile(rootDir, reference.relativePath, params.placeholders);
99
- bundledReferences.push([
100
- '---',
101
- '',
102
- `## Reference: ${reference.title}`,
103
- `Source path in the Codex bundle: \`${reference.relativePath}\``,
104
- '',
105
- rewriteLinksForSingleFile(stripLeadingTitle(content), spec),
106
- ].join('\n'));
107
- }
108
- return [core, ...bundledReferences].join('\n\n');
109
- }
110
- //# sourceMappingURL=skill-packaging.js.map