libretto 0.6.11 → 0.6.13

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.
Files changed (130) hide show
  1. package/README.md +7 -8
  2. package/README.template.md +7 -8
  3. package/dist/cli/cli.js +0 -22
  4. package/dist/cli/commands/browser.js +18 -24
  5. package/dist/cli/commands/execution.js +254 -234
  6. package/dist/cli/commands/experiments.js +100 -0
  7. package/dist/cli/commands/setup.js +3 -310
  8. package/dist/cli/commands/shared.js +10 -0
  9. package/dist/cli/commands/snapshot.js +46 -64
  10. package/dist/cli/commands/status.js +1 -40
  11. package/dist/cli/core/browser.js +303 -124
  12. package/dist/cli/core/config.js +5 -6
  13. package/dist/cli/core/context.js +4 -0
  14. package/dist/cli/core/daemon/config.js +0 -6
  15. package/dist/cli/core/daemon/daemon.js +497 -90
  16. package/dist/cli/core/daemon/ipc.js +170 -129
  17. package/dist/cli/core/daemon/snapshot.js +48 -9
  18. package/dist/cli/core/experiments.js +39 -0
  19. package/dist/cli/core/session.js +5 -4
  20. package/dist/cli/core/skill-version.js +2 -1
  21. package/dist/cli/core/workflow-runner/runner.js +147 -0
  22. package/dist/cli/core/workflow-runtime.js +60 -0
  23. package/dist/cli/index.js +0 -2
  24. package/dist/cli/router.js +4 -3
  25. package/dist/shared/debug/pause-handler.d.ts +9 -0
  26. package/dist/shared/debug/pause-handler.js +15 -0
  27. package/dist/shared/debug/pause.d.ts +1 -2
  28. package/dist/shared/debug/pause.js +13 -36
  29. package/dist/shared/instrumentation/instrument.js +4 -4
  30. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  31. package/dist/shared/ipc/child-process-transport.js +60 -0
  32. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  33. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  34. package/dist/shared/ipc/ipc.d.ts +46 -0
  35. package/dist/shared/ipc/ipc.js +165 -0
  36. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  37. package/dist/shared/ipc/ipc.spec.js +114 -0
  38. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  39. package/dist/shared/ipc/socket-transport.js +143 -0
  40. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  41. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  42. package/dist/shared/package-manager.d.ts +7 -0
  43. package/dist/shared/package-manager.js +60 -0
  44. package/dist/shared/paths/paths.d.ts +1 -8
  45. package/dist/shared/paths/paths.js +1 -49
  46. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  47. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  48. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  49. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  50. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  51. package/dist/shared/snapshot/render-snapshot.js +651 -0
  52. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  53. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  54. package/dist/shared/snapshot/types.d.ts +40 -0
  55. package/dist/shared/snapshot/types.js +0 -0
  56. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  58. package/dist/shared/state/session-state.d.ts +1 -0
  59. package/dist/shared/state/session-state.js +1 -0
  60. package/docs/experiments.md +67 -0
  61. package/docs/releasing.md +8 -6
  62. package/package.json +5 -2
  63. package/skills/libretto/SKILL.md +19 -19
  64. package/skills/libretto/references/configuration-file-reference.md +6 -12
  65. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  66. package/skills/libretto-readonly/SKILL.md +2 -9
  67. package/src/cli/AGENTS.md +7 -0
  68. package/src/cli/cli.ts +0 -23
  69. package/src/cli/commands/browser.ts +14 -18
  70. package/src/cli/commands/execution.ts +303 -271
  71. package/src/cli/commands/experiments.ts +120 -0
  72. package/src/cli/commands/setup.ts +3 -400
  73. package/src/cli/commands/shared.ts +20 -0
  74. package/src/cli/commands/snapshot.ts +54 -94
  75. package/src/cli/commands/status.ts +1 -48
  76. package/src/cli/core/browser.ts +372 -150
  77. package/src/cli/core/config.ts +4 -5
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +645 -107
  81. package/src/cli/core/daemon/ipc.ts +319 -214
  82. package/src/cli/core/daemon/snapshot.ts +71 -15
  83. package/src/cli/core/experiments.ts +56 -0
  84. package/src/cli/core/resolve-model.ts +5 -0
  85. package/src/cli/core/session.ts +5 -4
  86. package/src/cli/core/skill-version.ts +2 -1
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +86 -0
  89. package/src/cli/index.ts +0 -1
  90. package/src/cli/router.ts +4 -3
  91. package/src/shared/debug/pause-handler.ts +20 -0
  92. package/src/shared/debug/pause.ts +14 -48
  93. package/src/shared/instrumentation/instrument.ts +4 -4
  94. package/src/shared/ipc/AGENTS.md +24 -0
  95. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  96. package/src/shared/ipc/child-process-transport.ts +96 -0
  97. package/src/shared/ipc/ipc.spec.ts +161 -0
  98. package/src/shared/ipc/ipc.ts +288 -0
  99. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  100. package/src/shared/ipc/socket-transport.ts +189 -0
  101. package/src/shared/package-manager.ts +76 -0
  102. package/src/shared/paths/paths.ts +0 -72
  103. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  104. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  105. package/src/shared/snapshot/render-snapshot.ts +962 -0
  106. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  107. package/src/shared/snapshot/types.ts +43 -0
  108. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  109. package/src/shared/state/session-state.ts +1 -0
  110. package/dist/cli/commands/ai.js +0 -109
  111. package/dist/cli/core/ai-model.js +0 -192
  112. package/dist/cli/core/api-snapshot-analyzer.js +0 -86
  113. package/dist/cli/core/daemon/index.js +0 -16
  114. package/dist/cli/core/daemon/spawn.js +0 -90
  115. package/dist/cli/core/pause-signals.js +0 -29
  116. package/dist/cli/core/snapshot-analyzer.js +0 -666
  117. package/dist/cli/workers/run-integration-runtime.js +0 -235
  118. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  119. package/dist/cli/workers/run-integration-worker.js +0 -64
  120. package/scripts/summarize-evals.mjs +0 -135
  121. package/src/cli/commands/ai.ts +0 -143
  122. package/src/cli/core/ai-model.ts +0 -298
  123. package/src/cli/core/api-snapshot-analyzer.ts +0 -110
  124. package/src/cli/core/daemon/index.ts +0 -24
  125. package/src/cli/core/daemon/spawn.ts +0 -171
  126. package/src/cli/core/pause-signals.ts +0 -35
  127. package/src/cli/core/snapshot-analyzer.ts +0 -855
  128. package/src/cli/workers/run-integration-runtime.ts +0 -326
  129. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  130. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -1,666 +0,0 @@
1
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
- import { extname, isAbsolute, join, resolve } from "node:path";
3
- import { spawn } from "node:child_process";
4
- import { tmpdir } from "node:os";
5
- import { z } from "zod";
6
- const InterpretResultSchema = z.object({
7
- answer: z.string(),
8
- selectors: z.array(
9
- z.object({
10
- label: z.string(),
11
- selector: z.string(),
12
- rationale: z.string()
13
- })
14
- ),
15
- notes: z.string()
16
- });
17
- class UserCodingAgent {
18
- constructor(config) {
19
- this.config = config;
20
- }
21
- static resolveFromConfig(config) {
22
- switch (config.preset) {
23
- case "codex":
24
- return new CodexUserCodingAgent(config);
25
- case "claude":
26
- return new ClaudeUserCodingAgent(config);
27
- case "gemini":
28
- return new GeminiUserCodingAgent(config);
29
- }
30
- }
31
- static readConfiguredConfig() {
32
- return null;
33
- }
34
- static getConfigured() {
35
- const config = this.readConfiguredConfig();
36
- return config ? this.resolveFromConfig(config) : null;
37
- }
38
- get snapshotAnalyzerConfig() {
39
- return this.config;
40
- }
41
- get command() {
42
- const command = this.config.commandPrefix[0];
43
- if (!command) {
44
- throw new Error("AI config is invalid: command prefix is empty.");
45
- }
46
- return command;
47
- }
48
- get baseArgs() {
49
- return this.config.commandPrefix.slice(1);
50
- }
51
- screenshotHint(pngPath) {
52
- return `
53
-
54
- Screenshot file path: ${pngPath}
55
- Use the screenshot alongside the HTML snapshot context above.`;
56
- }
57
- async runAnalyzer(args, logger, stdinText) {
58
- const result = await runExternalCommand(
59
- this.command,
60
- args,
61
- logger,
62
- stdinText
63
- );
64
- if (result.exitCode !== 0) {
65
- throw new Error(
66
- `Analyzer command failed (${[this.command, ...args].join(" ")}).
67
- ${stripAnsi(result.stderr).trim() || stripAnsi(result.stdout).trim() || "No error output."}`
68
- );
69
- }
70
- return result;
71
- }
72
- async runAndParse(args, logger, stdinText) {
73
- const result = await this.runAnalyzer(args, logger, stdinText);
74
- return parseInterpretResultFromText(result.stdout);
75
- }
76
- }
77
- class CodexUserCodingAgent extends UserCodingAgent {
78
- async analyzeSnapshot(prompt, pngPath, logger) {
79
- const tempDir = mkdtempSync(join(tmpdir(), "libretto-analyzer-"));
80
- const outputPath = join(
81
- tempDir,
82
- `snapshot-analyzer-${Date.now()}-${Math.random().toString(36).slice(2)}.json`
83
- );
84
- const args = [
85
- ...this.baseArgs,
86
- "--output-last-message",
87
- outputPath,
88
- "-i",
89
- pngPath,
90
- "-"
91
- ];
92
- logger.info("interpret-analyzer-codex-start", {
93
- outputPath,
94
- pngPath,
95
- promptChars: prompt.length,
96
- args
97
- });
98
- const result = await this.runAnalyzer(args, logger, prompt);
99
- let outputText = result.stdout;
100
- try {
101
- logger.info("interpret-analyzer-codex-finish", {
102
- outputPath,
103
- outputFileExists: existsSync(outputPath),
104
- stdoutChars: result.stdout.length,
105
- stderrChars: result.stderr.length
106
- });
107
- if (existsSync(outputPath)) {
108
- outputText = readFileSync(outputPath, "utf-8");
109
- }
110
- return parseInterpretResultFromText(outputText);
111
- } finally {
112
- rmSync(tempDir, { recursive: true, force: true });
113
- }
114
- }
115
- }
116
- class ClaudeUserCodingAgent extends UserCodingAgent {
117
- async analyzeSnapshot(prompt, pngPath, logger) {
118
- return await this.runAndParse(
119
- [...this.baseArgs],
120
- logger,
121
- `${prompt}${this.screenshotHint(pngPath)}`
122
- );
123
- }
124
- }
125
- class GeminiUserCodingAgent extends UserCodingAgent {
126
- async analyzeSnapshot(prompt, pngPath, logger) {
127
- return await this.runAndParse(
128
- [...this.baseArgs],
129
- logger,
130
- `${prompt}${this.screenshotHint(pngPath)}`
131
- );
132
- }
133
- }
134
- async function runExternalCommand(command, args, logger, stdinText) {
135
- return await new Promise((resolve2, reject) => {
136
- const startedAt = Date.now();
137
- logger.info("interpret-analyzer-spawn-start", {
138
- command,
139
- args,
140
- stdinChars: stdinText?.length ?? 0
141
- });
142
- const child = spawn(command, args, {
143
- stdio: ["pipe", "pipe", "pipe"]
144
- });
145
- let stdout = "";
146
- let stderr = "";
147
- let stdinError = null;
148
- child.stdout.on("data", (chunk) => {
149
- stdout += chunk.toString();
150
- });
151
- child.stderr.on("data", (chunk) => {
152
- stderr += chunk.toString();
153
- });
154
- child.stdin.on("error", (err) => {
155
- stdinError = err;
156
- logger.warn("interpret-analyzer-stdin-pipe-error", {
157
- command,
158
- args,
159
- code: stdinError.code ?? null,
160
- message: stdinError.message,
161
- hint: stdinError.code === "EPIPE" ? "Child process exited before consuming all stdin data" : "Unexpected stdin write error"
162
- });
163
- });
164
- child.on("error", (err) => {
165
- logger.error("interpret-analyzer-spawn-error", {
166
- command,
167
- args,
168
- error: err
169
- });
170
- const error = err;
171
- if (error.code === "ENOENT") {
172
- reject(
173
- new Error(
174
- `Command not found: ${command}. Configure AI with 'libretto ai configure'.`
175
- )
176
- );
177
- return;
178
- }
179
- reject(err);
180
- });
181
- child.on("close", (code) => {
182
- const stdinNote = formatStdinError(stderr, stdinError);
183
- const combinedStderr = `${stderr}${stdinNote}`;
184
- logger.info("interpret-analyzer-spawn-close", {
185
- command,
186
- args,
187
- exitCode: code ?? 1,
188
- durationMs: Date.now() - startedAt,
189
- stdoutChars: stdout.length,
190
- stderrChars: combinedStderr.length,
191
- stdinErrorCode: stdinError?.code ?? null,
192
- stdoutPreview: summarizeForLog(stdout),
193
- stderrPreview: summarizeForLog(combinedStderr)
194
- });
195
- resolve2({
196
- exitCode: code ?? 1,
197
- stdout,
198
- stderr: combinedStderr
199
- });
200
- });
201
- try {
202
- if (stdinText !== void 0) {
203
- child.stdin.end(stdinText);
204
- } else {
205
- child.stdin.end();
206
- }
207
- } catch (err) {
208
- stdinError = err;
209
- logger.warn("interpret-analyzer-stdin-write-error", {
210
- command,
211
- args,
212
- code: stdinError.code ?? null,
213
- message: stdinError.message,
214
- hint: stdinError.code === "EPIPE" ? "Child process exited before consuming all stdin data" : "Unexpected stdin write error"
215
- });
216
- }
217
- });
218
- }
219
- function stripAnsi(value) {
220
- return value.replace(
221
- /\u001b\[[0-9;]*[A-Za-z]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g,
222
- ""
223
- );
224
- }
225
- function summarizeForLog(value, maxChars = 800) {
226
- const cleaned = stripAnsi(value).trim();
227
- if (!cleaned) return "";
228
- if (cleaned.length <= maxChars) return cleaned;
229
- return `${cleaned.slice(0, maxChars)}\u2026 [truncated ${cleaned.length - maxChars} chars]`;
230
- }
231
- function formatStdinError(stderr, error) {
232
- if (!error) return "";
233
- const detail = error.code === "EPIPE" ? "Analyzer closed stdin before Libretto finished sending the snapshot prompt." : `Analyzer stdin error: ${error.message}`;
234
- if (stderr.includes(detail)) return "";
235
- return `${stderr.endsWith("\n") || stderr.length === 0 ? "" : "\n"}${detail}
236
- `;
237
- }
238
- function extractJsonObjectCandidates(text) {
239
- const candidates = [];
240
- const seen = /* @__PURE__ */ new Set();
241
- const add = (value) => {
242
- const trimmed = value.trim();
243
- if (!trimmed || seen.has(trimmed)) return;
244
- seen.add(trimmed);
245
- candidates.push(trimmed);
246
- };
247
- try {
248
- const direct = text.trim();
249
- if (direct.startsWith("{") && direct.endsWith("}")) {
250
- add(direct);
251
- }
252
- } catch {
253
- }
254
- const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
255
- let codeBlockMatch;
256
- while ((codeBlockMatch = codeBlockRegex.exec(text)) !== null) {
257
- const body = codeBlockMatch[1]?.trim();
258
- if (body && body.startsWith("{") && body.endsWith("}")) {
259
- add(body);
260
- }
261
- }
262
- const lines = text.split("\n");
263
- for (const line of lines) {
264
- const trimmed = line.trim();
265
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
266
- add(trimmed);
267
- }
268
- }
269
- let depth = 0;
270
- let start = -1;
271
- let inString = false;
272
- let escaped = false;
273
- for (let i = 0; i < text.length; i++) {
274
- const char = text[i];
275
- if (inString) {
276
- if (escaped) {
277
- escaped = false;
278
- continue;
279
- }
280
- if (char === "\\") {
281
- escaped = true;
282
- continue;
283
- }
284
- if (char === '"') {
285
- inString = false;
286
- }
287
- continue;
288
- }
289
- if (char === '"') {
290
- inString = true;
291
- continue;
292
- }
293
- if (char === "{") {
294
- if (depth === 0) start = i;
295
- depth += 1;
296
- continue;
297
- }
298
- if (char === "}") {
299
- if (depth > 0) depth -= 1;
300
- if (depth === 0 && start >= 0) {
301
- add(text.slice(start, i + 1));
302
- start = -1;
303
- }
304
- }
305
- }
306
- return candidates;
307
- }
308
- function collectStringLeaves(value, out, depth = 0) {
309
- if (depth > 6 || value == null) return;
310
- if (typeof value === "string") {
311
- out.push(value);
312
- return;
313
- }
314
- if (Array.isArray(value)) {
315
- for (const item of value) {
316
- collectStringLeaves(item, out, depth + 1);
317
- }
318
- return;
319
- }
320
- if (typeof value === "object") {
321
- for (const nested of Object.values(value)) {
322
- collectStringLeaves(nested, out, depth + 1);
323
- }
324
- }
325
- }
326
- function parseInterpretResultFromText(text) {
327
- const cleaned = stripAnsi(text).trim();
328
- const candidates = extractJsonObjectCandidates(cleaned);
329
- if (candidates.length === 0) {
330
- throw new Error(
331
- "Analyzer output did not include a JSON object matching the interpret schema."
332
- );
333
- }
334
- for (const candidate of candidates) {
335
- try {
336
- const parsed = JSON.parse(candidate);
337
- const valid = InterpretResultSchema.safeParse(parsed);
338
- if (valid.success) {
339
- return valid.data;
340
- }
341
- const nestedStrings = [];
342
- collectStringLeaves(parsed, nestedStrings);
343
- for (const nestedText of nestedStrings) {
344
- const nestedCandidates = extractJsonObjectCandidates(nestedText);
345
- for (const nestedCandidate of nestedCandidates) {
346
- try {
347
- const nestedParsed = JSON.parse(nestedCandidate);
348
- const nestedValid = InterpretResultSchema.safeParse(nestedParsed);
349
- if (nestedValid.success) {
350
- return nestedValid.data;
351
- }
352
- } catch {
353
- }
354
- }
355
- }
356
- } catch {
357
- }
358
- }
359
- throw new Error(
360
- "Analyzer output could not be parsed as valid interpret JSON. Ensure the configured command returns only the requested JSON object."
361
- );
362
- }
363
- function resolvePath(filePath) {
364
- return isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
365
- }
366
- function getMimeType(filePath) {
367
- const ext = extname(filePath).toLowerCase();
368
- if (ext === ".png") return "image/png";
369
- if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
370
- if (ext === ".webp") return "image/webp";
371
- if (ext === ".gif") return "image/gif";
372
- return "application/octet-stream";
373
- }
374
- function readFileAsBase64(filePath) {
375
- return readFileSync(filePath).toString("base64");
376
- }
377
- function truncateText(text, maxChars) {
378
- if (text.length <= maxChars) {
379
- return { text, truncated: false };
380
- }
381
- const head = text.slice(0, Math.floor(maxChars * 0.6));
382
- const tail = text.slice(-Math.floor(maxChars * 0.4));
383
- return {
384
- text: `${head}
385
-
386
- ... [truncated] ...
387
-
388
- ${tail}`,
389
- truncated: true
390
- };
391
- }
392
- function collectSelectorHints(html, limit = 120) {
393
- const candidates = [];
394
- const seen = /* @__PURE__ */ new Set();
395
- const add = (value) => {
396
- if (candidates.length >= limit || seen.has(value)) return;
397
- seen.add(value);
398
- candidates.push(value);
399
- };
400
- const selectors = [
401
- { attr: "data-testid", format: (value) => `[data-testid="${value}"]` },
402
- { attr: "data-test", format: (value) => `[data-test="${value}"]` },
403
- { attr: "data-qa", format: (value) => `[data-qa="${value}"]` },
404
- { attr: "aria-label", format: (value) => `[aria-label="${value}"]` },
405
- { attr: "role", format: (value) => `[role="${value}"]` },
406
- { attr: "name", format: (value) => `[name="${value}"]` },
407
- { attr: "placeholder", format: (value) => `[placeholder="${value}"]` },
408
- { attr: "id", format: (value) => `#${value}` }
409
- ];
410
- for (const selector of selectors) {
411
- const regex = new RegExp(`${selector.attr}\\s*=\\s*["']([^"']+)["']`, "gi");
412
- let match;
413
- while ((match = regex.exec(html)) !== null) {
414
- const value = match[1]?.trim();
415
- if (!value) continue;
416
- add(selector.format(value));
417
- if (candidates.length >= limit) break;
418
- }
419
- if (candidates.length >= limit) break;
420
- }
421
- return candidates;
422
- }
423
- function estimateTokensFromChars(chars) {
424
- return Math.ceil(chars / 4);
425
- }
426
- function inferContextWindowTokens(model) {
427
- const normalized = model.trim().toLowerCase();
428
- if (normalized.includes("claude")) {
429
- return { contextWindowTokens: 2e5, source: "model:claude" };
430
- }
431
- if (normalized.includes("gpt-5") || normalized.includes("o3") || normalized.includes("o4")) {
432
- return { contextWindowTokens: 2e5, source: "model:openai" };
433
- }
434
- if (normalized.includes("gemini")) {
435
- return { contextWindowTokens: 1e6, source: "model:gemini" };
436
- }
437
- if (normalized.startsWith("openai/") || normalized.startsWith("codex/")) {
438
- return { contextWindowTokens: 2e5, source: "provider:openai" };
439
- }
440
- if (normalized.startsWith("anthropic/")) {
441
- return { contextWindowTokens: 2e5, source: "provider:anthropic" };
442
- }
443
- if (normalized.startsWith("google/") || normalized.startsWith("vertex/")) {
444
- return { contextWindowTokens: 1e6, source: "provider:google" };
445
- }
446
- return { contextWindowTokens: 128e3, source: "default" };
447
- }
448
- function buildSnapshotBudget(model) {
449
- const { contextWindowTokens, source } = inferContextWindowTokens(model);
450
- const outputReserveTokens = Math.min(
451
- 32e3,
452
- Math.max(8e3, Math.floor(contextWindowTokens * 0.1))
453
- );
454
- const promptBudgetTokens = Math.max(
455
- 8e3,
456
- contextWindowTokens - outputReserveTokens - 2e3
457
- );
458
- return {
459
- contextWindowTokens,
460
- outputReserveTokens,
461
- promptBudgetTokens,
462
- source
463
- };
464
- }
465
- function buildInterpretInstructions() {
466
- let prompt = `# Instructions
467
- `;
468
- prompt += `You are analyzing a screenshot and HTML snapshot of the same web page on behalf of an automation agent.
469
- `;
470
- prompt += `The agent needs to interact with this page programmatically using Playwright.
471
-
472
- `;
473
- prompt += `Based on the objective and context above:
474
- `;
475
- prompt += `1. Answer the objective concisely
476
- `;
477
- prompt += `2. Identify ALL interactive elements relevant to the objective and provide Playwright-ready CSS selectors
478
- `;
479
- prompt += `3. Note any relevant page state (loading indicators, error messages, disabled elements, modals/overlays)
480
- `;
481
- prompt += `4. If elements are inside iframes, identify the iframe selector and the element selector within it
482
-
483
- `;
484
- prompt += `Output JSON with this shape:
485
- `;
486
- prompt += `{"answer": string, "selectors": [{"label": string, "selector": string, "rationale": string}], "notes": string}
487
-
488
- `;
489
- prompt += `Selectors should prefer robust attributes: data-testid, data-test, aria-label, name, id, role. Avoid fragile class-based or positional selectors.
490
- `;
491
- prompt += `Only include selectors that exist in the HTML snapshot.
492
- `;
493
- return prompt;
494
- }
495
- function buildInlineHtmlPrompt(args, options) {
496
- const selectorHints = collectSelectorHints(options.htmlContent, 120);
497
- let prompt = `# Objective
498
- ${args.objective}
499
-
500
- `;
501
- prompt += `# Context
502
- ${args.context}
503
-
504
- `;
505
- prompt += `# Snapshot Selection
506
- `;
507
- prompt += `- Selected HTML snapshot: ${options.domLabel}
508
- `;
509
- prompt += `- Selection reason: ${options.selectionReason}
510
-
511
- `;
512
- prompt += buildInterpretInstructions();
513
- if (selectorHints.length > 0) {
514
- prompt += `
515
- Selector hints from HTML attributes (use if relevant):
516
- `;
517
- prompt += selectorHints.map((hint) => `- ${hint}`).join("\n");
518
- prompt += "\n";
519
- }
520
- if (options.truncated) {
521
- prompt += `
522
- HTML content is truncated to fit token limits.
523
- `;
524
- }
525
- prompt += `
526
- HTML snapshot (${options.domLabel}):
527
-
528
- ${options.htmlContent}`;
529
- prompt += "\n\nReturn only a JSON object. Do not include markdown code fences or extra commentary.";
530
- return prompt;
531
- }
532
- function buildInlinePromptSelection(args, fullHtmlContent, condensedHtmlContent, model) {
533
- const budget = buildSnapshotBudget(model);
534
- const stats = {
535
- fullDomChars: fullHtmlContent.length,
536
- fullDomEstimatedTokens: estimateTokensFromChars(fullHtmlContent.length),
537
- condensedDomChars: condensedHtmlContent.length,
538
- condensedDomEstimatedTokens: estimateTokensFromChars(
539
- condensedHtmlContent.length
540
- ),
541
- configuredModel: model
542
- };
543
- const buildCandidate = (domSource, htmlContent, selectionReason, truncated) => {
544
- const domLabel = domSource === "full" ? "full DOM" : "condensed DOM";
545
- const prompt = buildInlineHtmlPrompt(args, {
546
- htmlContent,
547
- domLabel,
548
- truncated,
549
- selectionReason,
550
- budget,
551
- stats
552
- });
553
- return {
554
- prompt,
555
- domSource,
556
- domLabel,
557
- htmlChars: htmlContent.length,
558
- htmlEstimatedTokens: estimateTokensFromChars(htmlContent.length),
559
- promptEstimatedTokens: estimateTokensFromChars(prompt.length),
560
- truncated,
561
- selectionReason,
562
- budget,
563
- stats
564
- };
565
- };
566
- const fullCandidate = buildCandidate(
567
- "full",
568
- fullHtmlContent,
569
- "placeholder",
570
- false
571
- );
572
- if (fullCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
573
- const selectionReason = `Full DOM fits within the estimated prompt budget (~${fullCandidate.promptEstimatedTokens.toLocaleString()} <= ${budget.promptBudgetTokens.toLocaleString()} tokens), so the analyzer receives the uncondensed page HTML.`;
574
- const prompt = buildInlineHtmlPrompt(args, {
575
- htmlContent: fullHtmlContent,
576
- domLabel: "full DOM",
577
- truncated: false,
578
- selectionReason,
579
- budget,
580
- stats
581
- });
582
- return {
583
- ...fullCandidate,
584
- selectionReason,
585
- prompt,
586
- promptEstimatedTokens: estimateTokensFromChars(prompt.length)
587
- };
588
- }
589
- const condensedReason = `Full DOM would exceed the estimated prompt budget (~${fullCandidate.promptEstimatedTokens.toLocaleString()} > ${budget.promptBudgetTokens.toLocaleString()} tokens), so the analyzer receives the condensed DOM instead.`;
590
- const condensedCandidate = buildCandidate(
591
- "condensed",
592
- condensedHtmlContent,
593
- condensedReason,
594
- false
595
- );
596
- if (condensedCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
597
- return condensedCandidate;
598
- }
599
- const truncateReason = `Both full and condensed DOM snapshots exceed the estimated prompt budget (full ~${fullCandidate.promptEstimatedTokens.toLocaleString()}, condensed ~${condensedCandidate.promptEstimatedTokens.toLocaleString()}, budget ${budget.promptBudgetTokens.toLocaleString()} tokens), so the condensed DOM is truncated to fit.`;
600
- const basePrompt = buildInlineHtmlPrompt(args, {
601
- htmlContent: "",
602
- domLabel: "condensed DOM",
603
- truncated: true,
604
- selectionReason: truncateReason,
605
- budget,
606
- stats
607
- });
608
- const availableHtmlTokens = Math.max(
609
- 2e3,
610
- budget.promptBudgetTokens - estimateTokensFromChars(basePrompt.length)
611
- );
612
- const truncatedHtml = truncateText(
613
- condensedHtmlContent,
614
- availableHtmlTokens * 4
615
- );
616
- return buildCandidate(
617
- "condensed",
618
- truncatedHtml.text,
619
- truncateReason,
620
- truncatedHtml.truncated
621
- );
622
- }
623
- async function runInterpret(args, logger) {
624
- logger.info("interpret-start", {
625
- objective: args.objective,
626
- pngPath: args.pngPath,
627
- htmlPath: args.htmlPath,
628
- condensedHtmlPath: args.condensedHtmlPath
629
- });
630
- process.env.NODE_ENV = "development";
631
- const pngPath = resolvePath(args.pngPath);
632
- const htmlPath = resolvePath(args.htmlPath);
633
- const condensedHtmlPath = resolvePath(args.condensedHtmlPath);
634
- if (!existsSync(pngPath)) {
635
- throw new Error(`PNG file not found: ${pngPath}`);
636
- }
637
- if (!existsSync(htmlPath)) {
638
- throw new Error(`HTML file not found: ${htmlPath}`);
639
- }
640
- if (!existsSync(condensedHtmlPath)) {
641
- throw new Error(`Condensed HTML file not found: ${condensedHtmlPath}`);
642
- }
643
- const fullHtmlContent = readFileSync(htmlPath, "utf-8");
644
- const condensedHtmlContent = readFileSync(condensedHtmlPath, "utf-8");
645
- const configuredAgent = UserCodingAgent.getConfigured();
646
- if (!configuredAgent) {
647
- throw new Error(
648
- "No AI config set. Run 'npx libretto ai configure codex' (or claude/gemini), or set API credentials in your .env file for direct API analysis."
649
- );
650
- }
651
- const configuredAnalyzer = configuredAgent.snapshotAnalyzerConfig;
652
- throw new Error(
653
- "The CLI-agent snapshot analysis path is not active. Update your config to the current format with `npx libretto ai configure <provider>`, or set API credentials in .env for direct API analysis."
654
- );
655
- }
656
- function canAnalyzeSnapshots() {
657
- return UserCodingAgent.getConfigured() !== null;
658
- }
659
- export {
660
- InterpretResultSchema,
661
- buildInlinePromptSelection,
662
- canAnalyzeSnapshots,
663
- getMimeType,
664
- readFileAsBase64,
665
- runInterpret
666
- };