libretto 0.3.1 → 0.4.0
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 +17 -7
- package/dist/cli/commands/ai.js +3 -5
- package/dist/cli/commands/browser.js +23 -2
- package/dist/cli/commands/init.js +157 -114
- package/dist/cli/commands/snapshot.js +147 -26
- package/dist/cli/core/ai-config.js +38 -46
- package/dist/cli/core/api-snapshot-analyzer.js +74 -0
- package/dist/cli/core/browser.js +21 -4
- package/dist/cli/core/context.js +1 -1
- package/dist/cli/core/snapshot-analyzer.js +295 -104
- package/dist/cli/core/snapshot-api-config.js +137 -0
- package/dist/cli/index.js +1 -0
- package/dist/shared/condense-dom/condense-dom.cjs +462 -0
- package/dist/shared/condense-dom/condense-dom.d.cts +34 -0
- package/dist/shared/condense-dom/condense-dom.d.ts +34 -0
- package/dist/shared/condense-dom/condense-dom.js +438 -0
- package/dist/shared/llm/ai-sdk-adapter.cjs +5 -1
- package/dist/shared/llm/ai-sdk-adapter.js +5 -1
- package/dist/shared/llm/client.cjs +106 -27
- package/dist/shared/llm/client.d.cts +8 -1
- package/dist/shared/llm/client.d.ts +8 -1
- package/dist/shared/llm/client.js +89 -23
- package/dist/shared/llm/types.d.cts +4 -3
- package/dist/shared/llm/types.d.ts +4 -3
- package/dist/shared/state/session-state.cjs +8 -1
- package/dist/shared/state/session-state.d.cts +24 -18
- package/dist/shared/state/session-state.d.ts +24 -18
- package/dist/shared/state/session-state.js +7 -1
- package/package.json +39 -33
|
@@ -8,13 +8,6 @@ import { extname, isAbsolute, join, resolve } from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import {
|
|
12
|
-
formatCommandPrefix,
|
|
13
|
-
readAiConfig
|
|
14
|
-
} from "./ai-config.js";
|
|
15
|
-
import {
|
|
16
|
-
getLLMClientFactory
|
|
17
|
-
} from "./context.js";
|
|
18
11
|
const InterpretResultSchema = z.object({
|
|
19
12
|
answer: z.string(),
|
|
20
13
|
selectors: z.array(
|
|
@@ -23,8 +16,8 @@ const InterpretResultSchema = z.object({
|
|
|
23
16
|
selector: z.string(),
|
|
24
17
|
rationale: z.string()
|
|
25
18
|
})
|
|
26
|
-
)
|
|
27
|
-
notes: z.string()
|
|
19
|
+
),
|
|
20
|
+
notes: z.string()
|
|
28
21
|
});
|
|
29
22
|
class UserCodingAgent {
|
|
30
23
|
constructor(config) {
|
|
@@ -41,7 +34,7 @@ class UserCodingAgent {
|
|
|
41
34
|
}
|
|
42
35
|
}
|
|
43
36
|
static readConfiguredConfig() {
|
|
44
|
-
return
|
|
37
|
+
return null;
|
|
45
38
|
}
|
|
46
39
|
static getConfigured() {
|
|
47
40
|
const config = this.readConfiguredConfig();
|
|
@@ -66,23 +59,23 @@ class UserCodingAgent {
|
|
|
66
59
|
Screenshot file path: ${pngPath}
|
|
67
60
|
Use the screenshot alongside the HTML snapshot context above.`;
|
|
68
61
|
}
|
|
69
|
-
async runAnalyzer(args, stdinText) {
|
|
70
|
-
const result = await runExternalCommand(this.command, args, stdinText);
|
|
62
|
+
async runAnalyzer(args, logger, stdinText) {
|
|
63
|
+
const result = await runExternalCommand(this.command, args, logger, stdinText);
|
|
71
64
|
if (result.exitCode !== 0) {
|
|
72
65
|
throw new Error(
|
|
73
|
-
`Analyzer command failed (${
|
|
66
|
+
`Analyzer command failed (${[this.command, ...args].join(" ")}).
|
|
74
67
|
${stripAnsi(result.stderr).trim() || stripAnsi(result.stdout).trim() || "No error output."}`
|
|
75
68
|
);
|
|
76
69
|
}
|
|
77
70
|
return result;
|
|
78
71
|
}
|
|
79
|
-
async runAndParse(args, stdinText) {
|
|
80
|
-
const result = await this.runAnalyzer(args, stdinText);
|
|
72
|
+
async runAndParse(args, logger, stdinText) {
|
|
73
|
+
const result = await this.runAnalyzer(args, logger, stdinText);
|
|
81
74
|
return parseInterpretResultFromText(result.stdout);
|
|
82
75
|
}
|
|
83
76
|
}
|
|
84
77
|
class CodexUserCodingAgent extends UserCodingAgent {
|
|
85
|
-
async analyzeSnapshot(prompt, pngPath) {
|
|
78
|
+
async analyzeSnapshot(prompt, pngPath, logger) {
|
|
86
79
|
const tempDir = mkdtempSync(join(tmpdir(), "libretto-cli-analyzer-"));
|
|
87
80
|
const outputPath = join(
|
|
88
81
|
tempDir,
|
|
@@ -96,9 +89,21 @@ class CodexUserCodingAgent extends UserCodingAgent {
|
|
|
96
89
|
pngPath,
|
|
97
90
|
"-"
|
|
98
91
|
];
|
|
99
|
-
|
|
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);
|
|
100
99
|
let outputText = result.stdout;
|
|
101
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
|
+
});
|
|
102
107
|
if (existsSync(outputPath)) {
|
|
103
108
|
outputText = readFileSync(outputPath, "utf-8");
|
|
104
109
|
}
|
|
@@ -109,31 +114,59 @@ class CodexUserCodingAgent extends UserCodingAgent {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
class ClaudeUserCodingAgent extends UserCodingAgent {
|
|
112
|
-
async analyzeSnapshot(prompt, pngPath) {
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
async analyzeSnapshot(prompt, pngPath, logger) {
|
|
118
|
+
return await this.runAndParse(
|
|
119
|
+
[...this.baseArgs],
|
|
120
|
+
logger,
|
|
121
|
+
`${prompt}${this.screenshotHint(pngPath)}`
|
|
122
|
+
);
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
class GeminiUserCodingAgent extends UserCodingAgent {
|
|
118
|
-
async analyzeSnapshot(prompt, pngPath) {
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
async analyzeSnapshot(prompt, pngPath, logger) {
|
|
127
|
+
return await this.runAndParse(
|
|
128
|
+
[...this.baseArgs],
|
|
129
|
+
logger,
|
|
130
|
+
`${prompt}${this.screenshotHint(pngPath)}`
|
|
131
|
+
);
|
|
121
132
|
}
|
|
122
133
|
}
|
|
123
|
-
async function runExternalCommand(command, args, stdinText) {
|
|
134
|
+
async function runExternalCommand(command, args, logger, stdinText) {
|
|
124
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
|
+
});
|
|
125
142
|
const child = spawn(command, args, {
|
|
126
143
|
stdio: ["pipe", "pipe", "pipe"]
|
|
127
144
|
});
|
|
128
145
|
let stdout = "";
|
|
129
146
|
let stderr = "";
|
|
147
|
+
let stdinError = null;
|
|
130
148
|
child.stdout.on("data", (chunk) => {
|
|
131
149
|
stdout += chunk.toString();
|
|
132
150
|
});
|
|
133
151
|
child.stderr.on("data", (chunk) => {
|
|
134
152
|
stderr += chunk.toString();
|
|
135
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
|
+
});
|
|
136
164
|
child.on("error", (err) => {
|
|
165
|
+
logger.error("interpret-analyzer-spawn-error", {
|
|
166
|
+
command,
|
|
167
|
+
args,
|
|
168
|
+
error: err
|
|
169
|
+
});
|
|
137
170
|
const error = err;
|
|
138
171
|
if (error.code === "ENOENT") {
|
|
139
172
|
reject(
|
|
@@ -146,16 +179,41 @@ async function runExternalCommand(command, args, stdinText) {
|
|
|
146
179
|
reject(err);
|
|
147
180
|
});
|
|
148
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
|
+
});
|
|
149
195
|
resolve2({
|
|
150
196
|
exitCode: code ?? 1,
|
|
151
197
|
stdout,
|
|
152
|
-
stderr
|
|
198
|
+
stderr: combinedStderr
|
|
153
199
|
});
|
|
154
200
|
});
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
});
|
|
157
216
|
}
|
|
158
|
-
child.stdin.end();
|
|
159
217
|
});
|
|
160
218
|
}
|
|
161
219
|
function stripAnsi(value) {
|
|
@@ -164,6 +222,19 @@ function stripAnsi(value) {
|
|
|
164
222
|
""
|
|
165
223
|
);
|
|
166
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
|
+
}
|
|
167
238
|
function extractJsonObjectCandidates(text) {
|
|
168
239
|
const candidates = [];
|
|
169
240
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -349,37 +420,50 @@ function collectSelectorHints(html, limit = 120) {
|
|
|
349
420
|
}
|
|
350
421
|
return candidates;
|
|
351
422
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const pngPath = resolvePath(args.pngPath);
|
|
360
|
-
const htmlPath = resolvePath(args.htmlPath);
|
|
361
|
-
if (!existsSync(pngPath)) {
|
|
362
|
-
throw new Error(`PNG file not found: ${pngPath}`);
|
|
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" };
|
|
363
430
|
}
|
|
364
|
-
if (
|
|
365
|
-
|
|
431
|
+
if (normalized.includes("gpt-5") || normalized.includes("o3") || normalized.includes("o4")) {
|
|
432
|
+
return { contextWindowTokens: 2e5, source: "model:openai" };
|
|
366
433
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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))
|
|
372
453
|
);
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
383
467
|
`;
|
|
384
468
|
prompt += `You are analyzing a screenshot and HTML snapshot of the same web page on behalf of an automation agent.
|
|
385
469
|
`;
|
|
@@ -405,66 +489,135 @@ ${args.context}
|
|
|
405
489
|
prompt += `Selectors should prefer robust attributes: data-testid, data-test, aria-label, name, id, role. Avoid fragile class-based or positional selectors.
|
|
406
490
|
`;
|
|
407
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}
|
|
408
503
|
|
|
409
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();
|
|
410
513
|
if (selectorHints.length > 0) {
|
|
411
|
-
prompt += `
|
|
514
|
+
prompt += `
|
|
515
|
+
Selector hints from HTML attributes (use if relevant):
|
|
412
516
|
`;
|
|
413
517
|
prompt += selectorHints.map((hint) => `- ${hint}`).join("\n");
|
|
414
|
-
prompt += "\n
|
|
518
|
+
prompt += "\n";
|
|
415
519
|
}
|
|
416
|
-
if (truncated) {
|
|
417
|
-
prompt += `
|
|
418
|
-
|
|
520
|
+
if (options.truncated) {
|
|
521
|
+
prompt += `
|
|
522
|
+
HTML content is truncated to fit token limits.
|
|
419
523
|
`;
|
|
420
524
|
}
|
|
421
|
-
prompt += `
|
|
525
|
+
prompt += `
|
|
526
|
+
HTML snapshot (${options.domLabel}):
|
|
422
527
|
|
|
423
|
-
${
|
|
528
|
+
${options.htmlContent}`;
|
|
424
529
|
prompt += "\n\nReturn only a JSON object. Do not include markdown code fences or extra commentary.";
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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(condensedHtmlContent.length),
|
|
539
|
+
configuredModel: model
|
|
540
|
+
};
|
|
541
|
+
const buildCandidate = (domSource, htmlContent, selectionReason, truncated) => {
|
|
542
|
+
const domLabel = domSource === "full" ? "full DOM" : "condensed DOM";
|
|
543
|
+
const prompt = buildInlineHtmlPrompt(args, {
|
|
544
|
+
htmlContent,
|
|
545
|
+
domLabel,
|
|
546
|
+
truncated,
|
|
547
|
+
selectionReason,
|
|
548
|
+
budget,
|
|
549
|
+
stats
|
|
432
550
|
});
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
551
|
+
return {
|
|
552
|
+
prompt,
|
|
553
|
+
domSource,
|
|
554
|
+
domLabel,
|
|
555
|
+
htmlChars: htmlContent.length,
|
|
556
|
+
htmlEstimatedTokens: estimateTokensFromChars(htmlContent.length),
|
|
557
|
+
promptEstimatedTokens: estimateTokensFromChars(prompt.length),
|
|
558
|
+
truncated,
|
|
559
|
+
selectionReason,
|
|
560
|
+
budget,
|
|
561
|
+
stats
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
const fullCandidate = buildCandidate(
|
|
565
|
+
"full",
|
|
566
|
+
fullHtmlContent,
|
|
567
|
+
"placeholder",
|
|
568
|
+
false
|
|
569
|
+
);
|
|
570
|
+
if (fullCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
|
|
571
|
+
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.`;
|
|
572
|
+
const prompt = buildInlineHtmlPrompt(args, {
|
|
573
|
+
htmlContent: fullHtmlContent,
|
|
574
|
+
domLabel: "full DOM",
|
|
575
|
+
truncated: false,
|
|
576
|
+
selectionReason,
|
|
577
|
+
budget,
|
|
578
|
+
stats
|
|
459
579
|
});
|
|
460
|
-
|
|
580
|
+
return {
|
|
581
|
+
...fullCandidate,
|
|
582
|
+
selectionReason,
|
|
583
|
+
prompt,
|
|
584
|
+
promptEstimatedTokens: estimateTokensFromChars(prompt.length)
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
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.`;
|
|
588
|
+
const condensedCandidate = buildCandidate(
|
|
589
|
+
"condensed",
|
|
590
|
+
condensedHtmlContent,
|
|
591
|
+
condensedReason,
|
|
592
|
+
false
|
|
593
|
+
);
|
|
594
|
+
if (condensedCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
|
|
595
|
+
return condensedCandidate;
|
|
461
596
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
597
|
+
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.`;
|
|
598
|
+
const basePrompt = buildInlineHtmlPrompt(args, {
|
|
599
|
+
htmlContent: "",
|
|
600
|
+
domLabel: "condensed DOM",
|
|
601
|
+
truncated: true,
|
|
602
|
+
selectionReason: truncateReason,
|
|
603
|
+
budget,
|
|
604
|
+
stats
|
|
465
605
|
});
|
|
606
|
+
const availableHtmlTokens = Math.max(
|
|
607
|
+
2e3,
|
|
608
|
+
budget.promptBudgetTokens - estimateTokensFromChars(basePrompt.length)
|
|
609
|
+
);
|
|
610
|
+
const truncatedHtml = truncateText(condensedHtmlContent, availableHtmlTokens * 4);
|
|
611
|
+
return buildCandidate(
|
|
612
|
+
"condensed",
|
|
613
|
+
truncatedHtml.text,
|
|
614
|
+
truncateReason,
|
|
615
|
+
truncatedHtml.truncated
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
function formatInterpretationOutput(parsed, header = "Interpretation:") {
|
|
466
619
|
const outputLines = [];
|
|
467
|
-
outputLines.push(
|
|
620
|
+
outputLines.push(header);
|
|
468
621
|
outputLines.push(`Answer: ${parsed.answer}`);
|
|
469
622
|
outputLines.push("");
|
|
470
623
|
if (parsed.selectors.length === 0) {
|
|
@@ -477,16 +630,54 @@ ${trimmedHtml}`;
|
|
|
477
630
|
outputLines.push(` rationale: ${selector.rationale}`);
|
|
478
631
|
});
|
|
479
632
|
}
|
|
480
|
-
if (parsed.notes.trim()) {
|
|
633
|
+
if (parsed.notes && parsed.notes.trim()) {
|
|
481
634
|
outputLines.push("");
|
|
482
635
|
outputLines.push(`Notes: ${parsed.notes.trim()}`);
|
|
483
636
|
}
|
|
484
|
-
|
|
637
|
+
return outputLines.join("\n");
|
|
638
|
+
}
|
|
639
|
+
async function runInterpret(args, logger) {
|
|
640
|
+
logger.info("interpret-start", {
|
|
641
|
+
objective: args.objective,
|
|
642
|
+
pngPath: args.pngPath,
|
|
643
|
+
htmlPath: args.htmlPath,
|
|
644
|
+
condensedHtmlPath: args.condensedHtmlPath
|
|
645
|
+
});
|
|
646
|
+
process.env.NODE_ENV = "development";
|
|
647
|
+
const pngPath = resolvePath(args.pngPath);
|
|
648
|
+
const htmlPath = resolvePath(args.htmlPath);
|
|
649
|
+
const condensedHtmlPath = resolvePath(args.condensedHtmlPath);
|
|
650
|
+
if (!existsSync(pngPath)) {
|
|
651
|
+
throw new Error(`PNG file not found: ${pngPath}`);
|
|
652
|
+
}
|
|
653
|
+
if (!existsSync(htmlPath)) {
|
|
654
|
+
throw new Error(`HTML file not found: ${htmlPath}`);
|
|
655
|
+
}
|
|
656
|
+
if (!existsSync(condensedHtmlPath)) {
|
|
657
|
+
throw new Error(`Condensed HTML file not found: ${condensedHtmlPath}`);
|
|
658
|
+
}
|
|
659
|
+
const fullHtmlContent = readFileSync(htmlPath, "utf-8");
|
|
660
|
+
const condensedHtmlContent = readFileSync(condensedHtmlPath, "utf-8");
|
|
661
|
+
const configuredAgent = UserCodingAgent.getConfigured();
|
|
662
|
+
if (!configuredAgent) {
|
|
663
|
+
throw new Error(
|
|
664
|
+
"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."
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
const configuredAnalyzer = configuredAgent.snapshotAnalyzerConfig;
|
|
668
|
+
throw new Error(
|
|
669
|
+
"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."
|
|
670
|
+
);
|
|
485
671
|
}
|
|
486
672
|
function canAnalyzeSnapshots() {
|
|
487
|
-
return UserCodingAgent.getConfigured() !== null
|
|
673
|
+
return UserCodingAgent.getConfigured() !== null;
|
|
488
674
|
}
|
|
489
675
|
export {
|
|
676
|
+
InterpretResultSchema,
|
|
677
|
+
buildInlinePromptSelection,
|
|
490
678
|
canAnalyzeSnapshots,
|
|
679
|
+
formatInterpretationOutput,
|
|
680
|
+
getMimeType,
|
|
681
|
+
readFileAsBase64,
|
|
491
682
|
runInterpret
|
|
492
683
|
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
readAiConfig
|
|
5
|
+
} from "./ai-config.js";
|
|
6
|
+
import { REPO_ROOT } from "./context.js";
|
|
7
|
+
import {
|
|
8
|
+
hasProviderCredentials,
|
|
9
|
+
missingProviderCredentialsMessage,
|
|
10
|
+
parseModel
|
|
11
|
+
} from "../../shared/llm/client.js";
|
|
12
|
+
const DEFAULT_SNAPSHOT_MODELS = {
|
|
13
|
+
openai: "openai/gpt-5.4",
|
|
14
|
+
anthropic: "anthropic/claude-sonnet-4-6",
|
|
15
|
+
google: "google/gemini-2.5-flash",
|
|
16
|
+
vertex: "vertex/gemini-2.5-pro"
|
|
17
|
+
};
|
|
18
|
+
class SnapshotApiUnavailableError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "SnapshotApiUnavailableError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function readWorktreeEnvPath() {
|
|
25
|
+
const gitPath = join(REPO_ROOT, ".git");
|
|
26
|
+
if (!existsSync(gitPath)) return null;
|
|
27
|
+
try {
|
|
28
|
+
const gitPointer = readFileSync(gitPath, "utf-8").trim();
|
|
29
|
+
const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
|
|
30
|
+
if (!match?.[1]) return null;
|
|
31
|
+
const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
|
|
32
|
+
const commonGitDir = resolve(worktreeGitDir, "..", "..");
|
|
33
|
+
return join(dirname(commonGitDir), ".env");
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function loadSnapshotEnv() {
|
|
39
|
+
if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
|
|
40
|
+
const envPathCandidates = [
|
|
41
|
+
join(REPO_ROOT, ".env"),
|
|
42
|
+
readWorktreeEnvPath()
|
|
43
|
+
].filter((value) => Boolean(value));
|
|
44
|
+
const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
|
|
45
|
+
if (!envPath) return;
|
|
46
|
+
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
|
47
|
+
const parsed = parseDotEnvAssignment(line);
|
|
48
|
+
if (!parsed) continue;
|
|
49
|
+
if (!(parsed.key in process.env)) {
|
|
50
|
+
process.env[parsed.key] = parsed.value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function parseDotEnvAssignment(line) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
57
|
+
const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed;
|
|
58
|
+
const eqIdx = withoutExport.indexOf("=");
|
|
59
|
+
if (eqIdx < 1) return null;
|
|
60
|
+
const key = withoutExport.slice(0, eqIdx).trim();
|
|
61
|
+
if (!key) return null;
|
|
62
|
+
const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
|
|
63
|
+
if (!rawValue) {
|
|
64
|
+
return { key, value: "" };
|
|
65
|
+
}
|
|
66
|
+
if (rawValue.startsWith('"')) {
|
|
67
|
+
const closeIdx = rawValue.indexOf('"', 1);
|
|
68
|
+
if (closeIdx > 0) {
|
|
69
|
+
return { key, value: rawValue.slice(1, closeIdx) };
|
|
70
|
+
}
|
|
71
|
+
return { key, value: rawValue.slice(1) };
|
|
72
|
+
}
|
|
73
|
+
if (rawValue.startsWith("'")) {
|
|
74
|
+
const closeIdx = rawValue.indexOf("'", 1);
|
|
75
|
+
if (closeIdx > 0) {
|
|
76
|
+
return { key, value: rawValue.slice(1, closeIdx) };
|
|
77
|
+
}
|
|
78
|
+
return { key, value: rawValue.slice(1) };
|
|
79
|
+
}
|
|
80
|
+
const inlineCommentIndex = rawValue.search(/\s#/);
|
|
81
|
+
const value = inlineCommentIndex >= 0 ? rawValue.slice(0, inlineCommentIndex).trimEnd() : rawValue.trim();
|
|
82
|
+
return { key, value };
|
|
83
|
+
}
|
|
84
|
+
function inferAutoSnapshotModel() {
|
|
85
|
+
const providersInPriorityOrder = [
|
|
86
|
+
"openai",
|
|
87
|
+
"anthropic",
|
|
88
|
+
"google",
|
|
89
|
+
"vertex"
|
|
90
|
+
];
|
|
91
|
+
for (const provider of providersInPriorityOrder) {
|
|
92
|
+
if (!hasProviderCredentials(provider)) continue;
|
|
93
|
+
return {
|
|
94
|
+
model: DEFAULT_SNAPSHOT_MODELS[provider],
|
|
95
|
+
provider,
|
|
96
|
+
source: `env:auto-${provider}`
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function resolveSnapshotApiModel(config = readAiConfig()) {
|
|
102
|
+
loadSnapshotEnv();
|
|
103
|
+
if (config?.model) {
|
|
104
|
+
const { provider } = parseModel(config.model);
|
|
105
|
+
return {
|
|
106
|
+
model: config.model,
|
|
107
|
+
provider,
|
|
108
|
+
source: "config"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return inferAutoSnapshotModel();
|
|
112
|
+
}
|
|
113
|
+
function resolveSnapshotApiModelOrThrow(config = readAiConfig()) {
|
|
114
|
+
const selection = resolveSnapshotApiModel(config);
|
|
115
|
+
if (!selection) {
|
|
116
|
+
throw new SnapshotApiUnavailableError(
|
|
117
|
+
"No API snapshot analyzer is available. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY/GOOGLE_GENERATIVE_AI_API_KEY, or GOOGLE_CLOUD_PROJECT, or run `npx libretto ai configure <provider>` to set a default model."
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!hasProviderCredentials(selection.provider)) {
|
|
121
|
+
throw new SnapshotApiUnavailableError(
|
|
122
|
+
missingProviderCredentialsMessage(selection.provider)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return selection;
|
|
126
|
+
}
|
|
127
|
+
function isSnapshotApiUnavailableError(error) {
|
|
128
|
+
return error instanceof SnapshotApiUnavailableError;
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
SnapshotApiUnavailableError,
|
|
132
|
+
isSnapshotApiUnavailableError,
|
|
133
|
+
loadSnapshotEnv,
|
|
134
|
+
parseDotEnvAssignment,
|
|
135
|
+
resolveSnapshotApiModel,
|
|
136
|
+
resolveSnapshotApiModelOrThrow
|
|
137
|
+
};
|
package/dist/cli/index.js
CHANGED