libretto 0.4.4 → 0.5.1

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 (194) hide show
  1. package/README.md +106 -36
  2. package/dist/cli/cli.js +39 -113
  3. package/dist/cli/commands/ai.js +1 -1
  4. package/dist/cli/commands/browser.js +87 -60
  5. package/dist/cli/commands/execution.js +201 -88
  6. package/dist/cli/commands/init.js +30 -8
  7. package/dist/cli/commands/logs.js +5 -6
  8. package/dist/cli/commands/shared.js +30 -29
  9. package/dist/cli/commands/snapshot.js +26 -39
  10. package/dist/cli/core/ai-config.js +9 -2
  11. package/dist/cli/core/api-snapshot-analyzer.js +15 -5
  12. package/dist/cli/core/browser.js +141 -33
  13. package/dist/cli/core/context.js +7 -18
  14. package/dist/cli/core/session-telemetry.js +5 -2
  15. package/dist/cli/core/session.js +23 -10
  16. package/dist/cli/core/snapshot-analyzer.js +16 -33
  17. package/dist/cli/core/snapshot-api-config.js +2 -6
  18. package/dist/cli/core/telemetry.js +10 -2
  19. package/dist/cli/framework/simple-cli.js +45 -25
  20. package/dist/cli/router.js +14 -21
  21. package/dist/cli/workers/run-integration-runtime.js +26 -7
  22. package/dist/cli/workers/run-integration-worker-protocol.js +3 -1
  23. package/dist/cli/workers/run-integration-worker.js +1 -4
  24. package/dist/index.d.ts +1 -2
  25. package/dist/index.js +7 -10
  26. package/dist/runtime/download/download.js +5 -1
  27. package/dist/runtime/extract/extract.js +11 -2
  28. package/dist/runtime/network/network.js +8 -1
  29. package/dist/runtime/recovery/agent.js +6 -2
  30. package/dist/runtime/recovery/errors.js +3 -1
  31. package/dist/runtime/recovery/recovery.js +3 -1
  32. package/dist/shared/condense-dom/condense-dom.js +6 -13
  33. package/dist/shared/config/config.d.ts +1 -9
  34. package/dist/shared/config/config.js +0 -18
  35. package/dist/shared/config/index.d.ts +2 -1
  36. package/dist/shared/config/index.js +0 -10
  37. package/dist/shared/debug/pause.js +9 -3
  38. package/dist/shared/instrumentation/instrument.js +101 -5
  39. package/dist/shared/llm/ai-sdk-adapter.js +3 -1
  40. package/dist/shared/llm/client.js +3 -1
  41. package/dist/shared/logger/index.js +4 -1
  42. package/dist/shared/paths/paths.js +2 -1
  43. package/dist/shared/paths/repo-root.d.ts +3 -0
  44. package/dist/shared/paths/repo-root.js +24 -0
  45. package/dist/shared/run/api.js +3 -1
  46. package/dist/shared/run/browser.js +7 -2
  47. package/dist/shared/state/session-state.d.ts +2 -1
  48. package/dist/shared/state/session-state.js +5 -2
  49. package/dist/shared/visualization/ghost-cursor.js +19 -10
  50. package/dist/shared/visualization/highlight.js +9 -6
  51. package/dist/shared/workflow/workflow.d.ts +4 -5
  52. package/dist/shared/workflow/workflow.js +3 -5
  53. package/package.json +11 -8
  54. package/scripts/check-skills-sync.mjs +25 -0
  55. package/scripts/compare-eval-summary.mjs +47 -0
  56. package/scripts/postinstall.mjs +26 -17
  57. package/scripts/prepare-release.sh +97 -0
  58. package/scripts/skills-libretto.mjs +103 -0
  59. package/scripts/summarize-evals.mjs +135 -0
  60. package/scripts/sync-skills.mjs +12 -0
  61. package/skills/libretto/SKILL.md +130 -377
  62. package/skills/libretto/references/auth-profiles.md +30 -0
  63. package/skills/libretto/{code-generation-rules.md → references/code-generation-rules.md} +27 -42
  64. package/skills/libretto/references/configuration-file-reference.md +53 -0
  65. package/skills/libretto/references/pages-and-page-targeting.md +29 -0
  66. package/skills/libretto/references/site-security-review.md +143 -0
  67. package/src/cli/cli.ts +86 -0
  68. package/src/cli/commands/ai.ts +35 -0
  69. package/src/cli/commands/browser.ts +189 -0
  70. package/src/cli/commands/execution.ts +822 -0
  71. package/src/cli/commands/init.ts +350 -0
  72. package/src/cli/commands/logs.ts +128 -0
  73. package/src/cli/commands/shared.ts +69 -0
  74. package/src/cli/commands/snapshot.ts +312 -0
  75. package/src/cli/core/ai-config.ts +264 -0
  76. package/src/cli/core/api-snapshot-analyzer.ts +108 -0
  77. package/src/cli/core/browser.ts +976 -0
  78. package/src/cli/core/context.ts +127 -0
  79. package/src/cli/core/pause-signals.ts +35 -0
  80. package/src/cli/core/session-telemetry.ts +564 -0
  81. package/src/cli/core/session.ts +223 -0
  82. package/src/cli/core/snapshot-analyzer.ts +855 -0
  83. package/src/cli/core/snapshot-api-config.ts +231 -0
  84. package/src/cli/core/telemetry.ts +459 -0
  85. package/src/cli/framework/simple-cli.ts +1340 -0
  86. package/src/cli/index.ts +13 -0
  87. package/src/cli/router.ts +20 -0
  88. package/src/cli/workers/run-integration-runtime.ts +338 -0
  89. package/src/cli/workers/run-integration-worker-protocol.ts +16 -0
  90. package/src/cli/workers/run-integration-worker.ts +72 -0
  91. package/src/index.ts +127 -0
  92. package/src/runtime/download/download.ts +104 -0
  93. package/src/runtime/download/index.ts +7 -0
  94. package/src/runtime/extract/extract.ts +102 -0
  95. package/src/runtime/extract/index.ts +1 -0
  96. package/src/runtime/network/index.ts +5 -0
  97. package/src/runtime/network/network.ts +119 -0
  98. package/{dist/runtime/recovery/agent.cjs → src/runtime/recovery/agent.ts} +114 -76
  99. package/src/runtime/recovery/errors.ts +155 -0
  100. package/src/runtime/recovery/index.ts +7 -0
  101. package/src/runtime/recovery/recovery.ts +53 -0
  102. package/{dist/shared/condense-dom/condense-dom.cjs → src/shared/condense-dom/condense-dom.ts} +249 -124
  103. package/src/shared/config/config.ts +3 -0
  104. package/src/shared/config/index.ts +0 -0
  105. package/src/shared/debug/index.ts +1 -0
  106. package/src/shared/debug/pause.ts +91 -0
  107. package/src/shared/instrumentation/errors.ts +84 -0
  108. package/src/shared/instrumentation/index.ts +9 -0
  109. package/src/shared/instrumentation/instrument.ts +406 -0
  110. package/src/shared/llm/ai-sdk-adapter.ts +81 -0
  111. package/{dist/shared/llm/client.cjs → src/shared/llm/client.ts} +86 -80
  112. package/src/shared/llm/index.ts +3 -0
  113. package/src/shared/llm/types.ts +63 -0
  114. package/src/shared/logger/index.ts +13 -0
  115. package/src/shared/logger/logger.ts +358 -0
  116. package/src/shared/logger/sinks.ts +148 -0
  117. package/src/shared/paths/paths.ts +110 -0
  118. package/src/shared/paths/repo-root.ts +27 -0
  119. package/src/shared/run/api.ts +6 -0
  120. package/src/shared/run/browser.ts +107 -0
  121. package/src/shared/state/index.ts +11 -0
  122. package/src/shared/state/session-state.ts +77 -0
  123. package/src/shared/visualization/ghost-cursor.ts +213 -0
  124. package/src/shared/visualization/highlight.ts +149 -0
  125. package/src/shared/visualization/index.ts +18 -0
  126. package/src/shared/workflow/workflow.ts +36 -0
  127. package/dist/index.cjs +0 -144
  128. package/dist/index.d.cts +0 -21
  129. package/dist/runtime/download/download.cjs +0 -70
  130. package/dist/runtime/download/download.d.cts +0 -35
  131. package/dist/runtime/download/index.cjs +0 -30
  132. package/dist/runtime/download/index.d.cts +0 -3
  133. package/dist/runtime/extract/extract.cjs +0 -88
  134. package/dist/runtime/extract/extract.d.cts +0 -23
  135. package/dist/runtime/extract/index.cjs +0 -28
  136. package/dist/runtime/extract/index.d.cts +0 -5
  137. package/dist/runtime/network/index.cjs +0 -28
  138. package/dist/runtime/network/index.d.cts +0 -4
  139. package/dist/runtime/network/network.cjs +0 -91
  140. package/dist/runtime/network/network.d.cts +0 -28
  141. package/dist/runtime/recovery/agent.d.cts +0 -13
  142. package/dist/runtime/recovery/errors.cjs +0 -124
  143. package/dist/runtime/recovery/errors.d.cts +0 -31
  144. package/dist/runtime/recovery/index.cjs +0 -34
  145. package/dist/runtime/recovery/index.d.cts +0 -7
  146. package/dist/runtime/recovery/recovery.cjs +0 -55
  147. package/dist/runtime/recovery/recovery.d.cts +0 -12
  148. package/dist/shared/condense-dom/condense-dom.d.cts +0 -34
  149. package/dist/shared/config/config.cjs +0 -44
  150. package/dist/shared/config/config.d.cts +0 -10
  151. package/dist/shared/config/index.cjs +0 -32
  152. package/dist/shared/config/index.d.cts +0 -1
  153. package/dist/shared/debug/index.cjs +0 -28
  154. package/dist/shared/debug/index.d.cts +0 -1
  155. package/dist/shared/debug/pause.cjs +0 -86
  156. package/dist/shared/debug/pause.d.cts +0 -12
  157. package/dist/shared/instrumentation/errors.cjs +0 -81
  158. package/dist/shared/instrumentation/errors.d.cts +0 -12
  159. package/dist/shared/instrumentation/index.cjs +0 -35
  160. package/dist/shared/instrumentation/index.d.cts +0 -6
  161. package/dist/shared/instrumentation/instrument.cjs +0 -206
  162. package/dist/shared/instrumentation/instrument.d.cts +0 -32
  163. package/dist/shared/llm/ai-sdk-adapter.cjs +0 -71
  164. package/dist/shared/llm/ai-sdk-adapter.d.cts +0 -22
  165. package/dist/shared/llm/client.d.cts +0 -13
  166. package/dist/shared/llm/index.cjs +0 -31
  167. package/dist/shared/llm/index.d.cts +0 -5
  168. package/dist/shared/llm/types.cjs +0 -16
  169. package/dist/shared/llm/types.d.cts +0 -67
  170. package/dist/shared/logger/index.cjs +0 -37
  171. package/dist/shared/logger/index.d.cts +0 -2
  172. package/dist/shared/logger/logger.cjs +0 -232
  173. package/dist/shared/logger/logger.d.cts +0 -86
  174. package/dist/shared/logger/sinks.cjs +0 -160
  175. package/dist/shared/logger/sinks.d.cts +0 -9
  176. package/dist/shared/paths/paths.cjs +0 -104
  177. package/dist/shared/paths/paths.d.cts +0 -10
  178. package/dist/shared/run/api.cjs +0 -28
  179. package/dist/shared/run/api.d.cts +0 -2
  180. package/dist/shared/run/browser.cjs +0 -98
  181. package/dist/shared/run/browser.d.cts +0 -22
  182. package/dist/shared/state/index.cjs +0 -38
  183. package/dist/shared/state/index.d.cts +0 -2
  184. package/dist/shared/state/session-state.cjs +0 -92
  185. package/dist/shared/state/session-state.d.cts +0 -40
  186. package/dist/shared/visualization/ghost-cursor.cjs +0 -174
  187. package/dist/shared/visualization/ghost-cursor.d.cts +0 -37
  188. package/dist/shared/visualization/highlight.cjs +0 -134
  189. package/dist/shared/visualization/highlight.d.cts +0 -22
  190. package/dist/shared/visualization/index.cjs +0 -45
  191. package/dist/shared/visualization/index.d.cts +0 -3
  192. package/dist/shared/workflow/workflow.cjs +0 -47
  193. package/dist/shared/workflow/workflow.d.cts +0 -21
  194. package/skills/libretto/integration-approach-selection.md +0 -174
@@ -0,0 +1,312 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import type { LoggerApi } from "../../shared/logger/index.js";
4
+ import { connect, disconnectBrowser } from "../core/browser.js";
5
+ import { getSessionSnapshotRunDir } from "../core/context.js";
6
+ import { condenseDom } from "../../shared/condense-dom/condense-dom.js";
7
+ import { readSessionState } from "../core/session.js";
8
+ import {
9
+ type InterpretArgs,
10
+ type ScreenshotPair,
11
+ } from "../core/snapshot-analyzer.js";
12
+ import { SimpleCLI } from "../framework/simple-cli.js";
13
+ import { pageOption, sessionOption, withRequiredSession } from "./shared.js";
14
+ import { runApiInterpret } from "../core/api-snapshot-analyzer.js";
15
+ import { readAiConfig } from "../core/ai-config.js";
16
+ import { resolveSnapshotApiModelOrThrow } from "../core/snapshot-api-config.js";
17
+
18
+ const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
19
+
20
+ function generateSnapshotRunId(): string {
21
+ return `snapshot-${Date.now()}`;
22
+ }
23
+
24
+ type SnapshotViewportMetrics = {
25
+ configuredWidth: number | null;
26
+ configuredHeight: number | null;
27
+ innerWidth: number | null;
28
+ innerHeight: number | null;
29
+ };
30
+
31
+ function isZeroViewport(value: number | null): boolean {
32
+ return typeof value === "number" && value <= 0;
33
+ }
34
+
35
+ function shouldForceSnapshotViewport(
36
+ metrics: SnapshotViewportMetrics,
37
+ ): boolean {
38
+ return (
39
+ isZeroViewport(metrics.configuredWidth) ||
40
+ isZeroViewport(metrics.configuredHeight) ||
41
+ isZeroViewport(metrics.innerWidth) ||
42
+ isZeroViewport(metrics.innerHeight)
43
+ );
44
+ }
45
+
46
+ function isZeroWidthScreenshotError(error: unknown): boolean {
47
+ return (
48
+ error instanceof Error &&
49
+ error.message.includes("Cannot take screenshot with 0 width")
50
+ );
51
+ }
52
+
53
+ async function readSnapshotViewportMetrics(page: {
54
+ viewportSize(): { width: number; height: number } | null;
55
+ evaluate<T>(pageFunction: () => T | Promise<T>): Promise<T>;
56
+ }): Promise<SnapshotViewportMetrics> {
57
+ const configuredViewport = page.viewportSize();
58
+ let innerWidth: number | null = null;
59
+ let innerHeight: number | null = null;
60
+
61
+ try {
62
+ const innerViewport = await page.evaluate(() => ({
63
+ width: window.innerWidth,
64
+ height: window.innerHeight,
65
+ }));
66
+ innerWidth = innerViewport.width;
67
+ innerHeight = innerViewport.height;
68
+ } catch {}
69
+
70
+ return {
71
+ configuredWidth: configuredViewport?.width ?? null,
72
+ configuredHeight: configuredViewport?.height ?? null,
73
+ innerWidth,
74
+ innerHeight,
75
+ };
76
+ }
77
+
78
+ function resolveSnapshotViewport(
79
+ session: string,
80
+ logger: LoggerApi,
81
+ ): { width: number; height: number } {
82
+ const state = readSessionState(session, logger);
83
+ if (state?.viewport) {
84
+ logger.info("screenshot-viewport-from-session-state", {
85
+ session,
86
+ viewport: state.viewport,
87
+ });
88
+ return state.viewport;
89
+ }
90
+ logger.info("screenshot-viewport-fallback", {
91
+ session,
92
+ reason: "no viewport in session state",
93
+ viewport: FALLBACK_SNAPSHOT_VIEWPORT,
94
+ });
95
+ return FALLBACK_SNAPSHOT_VIEWPORT;
96
+ }
97
+
98
+ async function forceSnapshotViewport(
99
+ page: {
100
+ setViewportSize(size: { width: number; height: number }): Promise<void>;
101
+ },
102
+ viewport: { width: number; height: number },
103
+ logger: LoggerApi,
104
+ session: string,
105
+ pageId?: string,
106
+ reason?: string,
107
+ ): Promise<void> {
108
+ await page.setViewportSize(viewport);
109
+ logger.warn("screenshot-viewport-forced", {
110
+ session,
111
+ pageId,
112
+ reason,
113
+ viewport,
114
+ });
115
+ }
116
+
117
+ async function captureScreenshot(
118
+ session: string,
119
+ logger: LoggerApi,
120
+ pageId?: string,
121
+ ): Promise<ScreenshotPair> {
122
+ logger.info("screenshot-start", { session, pageId });
123
+ const snapshotRunId = generateSnapshotRunId();
124
+ const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
125
+ mkdirSync(snapshotRunDir, { recursive: true });
126
+ const { browser, page } = await connect(session, logger, 10000, {
127
+ pageId,
128
+ requireSinglePage: true,
129
+ });
130
+
131
+ try {
132
+ let title: string | null = null;
133
+ try {
134
+ title = await page.title();
135
+ } catch (error) {
136
+ logger.warn("screenshot-title-read-failed", {
137
+ session,
138
+ pageId,
139
+ error,
140
+ });
141
+ }
142
+
143
+ let pageUrl: string | null = null;
144
+ try {
145
+ pageUrl = page.url();
146
+ } catch (error) {
147
+ logger.warn("screenshot-url-read-failed", {
148
+ session,
149
+ pageId,
150
+ error,
151
+ });
152
+ }
153
+
154
+ const pngPath = `${snapshotRunDir}/page.png`;
155
+ const htmlPath = `${snapshotRunDir}/page.html`;
156
+ const condensedHtmlPath = `${snapshotRunDir}/page.condensed.html`;
157
+
158
+ const RENDER_SETTLE_TIMEOUT_MS = 10_000;
159
+ await Promise.race([
160
+ page.waitForLoadState("networkidle").catch(() => {}),
161
+ new Promise((resolve) => setTimeout(resolve, RENDER_SETTLE_TIMEOUT_MS)),
162
+ ]);
163
+
164
+ const restoreViewport = resolveSnapshotViewport(session, logger);
165
+ const viewportMetrics = await readSnapshotViewportMetrics(page);
166
+ logger.info("screenshot-viewport-metrics", {
167
+ session,
168
+ pageId,
169
+ restoreViewport,
170
+ ...viewportMetrics,
171
+ });
172
+ await forceSnapshotViewport(
173
+ page,
174
+ restoreViewport,
175
+ logger,
176
+ session,
177
+ pageId,
178
+ shouldForceSnapshotViewport(viewportMetrics)
179
+ ? "preflight-invalid-viewport"
180
+ : "preflight-normalize-viewport",
181
+ );
182
+
183
+ try {
184
+ await page.screenshot({ path: pngPath });
185
+ } catch (error) {
186
+ if (!isZeroWidthScreenshotError(error)) {
187
+ throw error;
188
+ }
189
+ await forceSnapshotViewport(
190
+ page,
191
+ restoreViewport,
192
+ logger,
193
+ session,
194
+ pageId,
195
+ "retry-after-zero-width-screenshot-error",
196
+ );
197
+ await page.screenshot({ path: pngPath });
198
+ }
199
+
200
+ const htmlContent = await page.content();
201
+ const fs = await import("node:fs/promises");
202
+ await fs.writeFile(htmlPath, htmlContent);
203
+
204
+ // Write condensed DOM
205
+ const condenseResult = condenseDom(htmlContent);
206
+ await fs.writeFile(condensedHtmlPath, condenseResult.html);
207
+
208
+ logger.info("screenshot-success", {
209
+ session,
210
+ pageUrl,
211
+ title,
212
+ pngPath,
213
+ htmlPath,
214
+ condensedHtmlPath,
215
+ snapshotRunId,
216
+ domCondenseStats: {
217
+ originalLength: condenseResult.originalLength,
218
+ condensedLength: condenseResult.condensedLength,
219
+ reductions: condenseResult.reductions,
220
+ },
221
+ });
222
+ return { pngPath, htmlPath, condensedHtmlPath, baseName: snapshotRunId };
223
+ } catch (err) {
224
+ let pageAlive = false;
225
+ let browserConnected = false;
226
+ try {
227
+ browserConnected = browser.isConnected();
228
+ pageAlive = !page.isClosed();
229
+ } catch {}
230
+ logger.error("screenshot-error", {
231
+ error: err,
232
+ session,
233
+ pageAlive,
234
+ browserConnected,
235
+ pageUrl: (() => {
236
+ try {
237
+ return page.url();
238
+ } catch {
239
+ return null;
240
+ }
241
+ })(),
242
+ });
243
+ throw err;
244
+ } finally {
245
+ disconnectBrowser(browser, logger, session);
246
+ }
247
+ }
248
+
249
+ async function runSnapshot(
250
+ session: string,
251
+ logger: LoggerApi,
252
+ pageId: string | undefined,
253
+ objective: string,
254
+ context: string,
255
+ ): Promise<void> {
256
+ const normalizedObjective = objective.trim();
257
+ const normalizedContext = context.trim();
258
+
259
+ const configuredAi = readAiConfig();
260
+ resolveSnapshotApiModelOrThrow(configuredAi);
261
+
262
+ const { pngPath, htmlPath, condensedHtmlPath } = await captureScreenshot(
263
+ session,
264
+ logger,
265
+ pageId,
266
+ );
267
+
268
+ console.log("Screenshot saved:");
269
+ console.log(` PNG: ${pngPath}`);
270
+ console.log(` HTML: ${htmlPath}`);
271
+ console.log(` Condensed HTML: ${condensedHtmlPath}`);
272
+
273
+ const interpretArgs: InterpretArgs = {
274
+ objective: normalizedObjective,
275
+ session,
276
+ context: normalizedContext,
277
+ pngPath,
278
+ htmlPath,
279
+ condensedHtmlPath,
280
+ };
281
+
282
+ // Analysis uses direct API calls via the Vercel AI SDK (see api-snapshot-analyzer.ts).
283
+ // The legacy CLI-agent path (spawning codex/claude/gemini as a subprocess) is preserved
284
+ // in snapshot-analyzer.ts — to switch back, replace this call with:
285
+ // await runInterpret(interpretArgs, logger);
286
+ await runApiInterpret(interpretArgs, logger, configuredAi);
287
+ }
288
+
289
+ export const snapshotInput = SimpleCLI.input({
290
+ positionals: [],
291
+ named: {
292
+ session: sessionOption(),
293
+ page: pageOption(),
294
+ objective: SimpleCLI.option(z.string()),
295
+ context: SimpleCLI.option(z.string()),
296
+ },
297
+ });
298
+
299
+ export const snapshotCommand = SimpleCLI.command({
300
+ description: "Capture PNG + HTML and analyze with --objective and --context",
301
+ })
302
+ .input(snapshotInput)
303
+ .use(withRequiredSession())
304
+ .handle(async ({ input, ctx }) => {
305
+ await runSnapshot(
306
+ ctx.session,
307
+ ctx.logger,
308
+ input.page,
309
+ input.objective,
310
+ input.context,
311
+ );
312
+ });
@@ -0,0 +1,264 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import { LIBRETTO_CONFIG_PATH } from "./context.js";
5
+
6
+ export const CURRENT_CONFIG_VERSION = 1;
7
+
8
+ /**
9
+ * AI configuration schema.
10
+ *
11
+ * The `model` field is a provider/model-id string (e.g. "openai/gpt-5.4",
12
+ * "anthropic/claude-sonnet-4-6", "google/gemini-3-flash-preview", "vertex/gemini-2.5-pro").
13
+ *
14
+ * Legacy note: earlier versions stored a `preset` (codex|claude|gemini) and
15
+ * `commandPrefix` (CLI args to spawn a sub-agent process). That approach has
16
+ * been replaced by direct API calls via the Vercel AI SDK. The legacy CLI-agent
17
+ * code is preserved in snapshot-analyzer.ts but is not wired into the snapshot
18
+ * command.
19
+ */
20
+ export const AiConfigSchema = z.object({
21
+ model: z.string().min(1),
22
+ updatedAt: z.string(),
23
+ });
24
+ export type AiConfig = z.infer<typeof AiConfigSchema>;
25
+
26
+ export const ViewportConfigSchema = z.object({
27
+ width: z.number().int().min(1),
28
+ height: z.number().int().min(1),
29
+ });
30
+ export type ViewportConfig = z.infer<typeof ViewportConfigSchema>;
31
+
32
+ export const LibrettoConfigSchema = z
33
+ .object({
34
+ version: z.literal(CURRENT_CONFIG_VERSION),
35
+ ai: AiConfigSchema.optional(),
36
+ viewport: ViewportConfigSchema.optional(),
37
+ })
38
+ .passthrough();
39
+ export type LibrettoConfig = z.infer<typeof LibrettoConfigSchema>;
40
+
41
+ /** Default models for each provider shorthand accepted by `ai configure`. */
42
+ const DEFAULT_MODELS: Record<string, string> = {
43
+ openai: "openai/gpt-5.4",
44
+ anthropic: "anthropic/claude-sonnet-4-6",
45
+ gemini: "google/gemini-3-flash-preview",
46
+ vertex: "vertex/gemini-2.5-pro",
47
+ };
48
+
49
+ const PROVIDER_ALIASES: Record<string, string> = {
50
+ claude: DEFAULT_MODELS.anthropic,
51
+ google: DEFAULT_MODELS.gemini,
52
+ };
53
+
54
+ const CONFIGURE_PROVIDERS = [
55
+ "openai",
56
+ "anthropic",
57
+ "gemini",
58
+ "vertex",
59
+ ] as const;
60
+
61
+ function formatConfigureProviders(separator = " | "): string {
62
+ return CONFIGURE_PROVIDERS.join(separator);
63
+ }
64
+
65
+ function formatConfigIssues(error: z.ZodError): string {
66
+ return error.issues
67
+ .map((issue) => ` - ${issue.path.join(".") || "root"}: ${issue.message}`)
68
+ .join("\n");
69
+ }
70
+
71
+ function formatExpectedConfigExample(): string {
72
+ return JSON.stringify(
73
+ {
74
+ version: CURRENT_CONFIG_VERSION,
75
+ ai: {
76
+ model: "openai/gpt-5.4",
77
+ updatedAt: "2026-01-01T00:00:00.000Z",
78
+ },
79
+ viewport: {
80
+ width: 1280,
81
+ height: 800,
82
+ },
83
+ },
84
+ null,
85
+ 2,
86
+ );
87
+ }
88
+
89
+ function invalidConfigError(configPath: string, detail?: string): Error {
90
+ return new Error(
91
+ [
92
+ `AI config is invalid at ${configPath}.`,
93
+ detail ? `Problems:\n${detail}` : null,
94
+ "Expected config example:",
95
+ formatExpectedConfigExample(),
96
+ "Notes:",
97
+ ' - "ai" and "viewport" are optional.',
98
+ ' - "ai.model" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
99
+ "Fix the file to match this shape, or delete it and rerun:",
100
+ ` npx libretto ai configure ${formatConfigureProviders()}`,
101
+ ]
102
+ .filter(Boolean)
103
+ .join("\n"),
104
+ );
105
+ }
106
+
107
+ function parseConfig(raw: string, configPath: string): LibrettoConfig {
108
+ let parsedJson: unknown;
109
+ try {
110
+ parsedJson = JSON.parse(raw);
111
+ } catch (error) {
112
+ throw invalidConfigError(
113
+ configPath,
114
+ ` - root: Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
115
+ );
116
+ }
117
+
118
+ const parsed = LibrettoConfigSchema.safeParse(parsedJson);
119
+ if (!parsed.success) {
120
+ throw invalidConfigError(configPath, formatConfigIssues(parsed.error));
121
+ }
122
+ return parsed.data;
123
+ }
124
+
125
+ export function readLibrettoConfig(
126
+ configPath: string = LIBRETTO_CONFIG_PATH,
127
+ ): LibrettoConfig {
128
+ if (!existsSync(configPath)) {
129
+ return { version: CURRENT_CONFIG_VERSION };
130
+ }
131
+ return parseConfig(readFileSync(configPath, "utf-8"), configPath);
132
+ }
133
+
134
+ export function writeLibrettoConfig(
135
+ config: LibrettoConfig,
136
+ configPath: string = LIBRETTO_CONFIG_PATH,
137
+ ): LibrettoConfig {
138
+ const parsed = LibrettoConfigSchema.parse(config);
139
+ mkdirSync(dirname(configPath), { recursive: true });
140
+ writeFileSync(configPath, JSON.stringify(parsed, null, 2), "utf-8");
141
+ return parsed;
142
+ }
143
+
144
+ export function readAiConfig(
145
+ configPath: string = LIBRETTO_CONFIG_PATH,
146
+ ): AiConfig | null {
147
+ return readLibrettoConfig(configPath).ai ?? null;
148
+ }
149
+
150
+ export function writeAiConfig(
151
+ model: string,
152
+ configPath: string = LIBRETTO_CONFIG_PATH,
153
+ ): AiConfig {
154
+ const librettoConfig = readLibrettoConfig(configPath);
155
+ const ai = AiConfigSchema.parse({
156
+ model,
157
+ updatedAt: new Date().toISOString(),
158
+ });
159
+ writeLibrettoConfig(
160
+ {
161
+ ...librettoConfig,
162
+ version: CURRENT_CONFIG_VERSION,
163
+ ai,
164
+ },
165
+ configPath,
166
+ );
167
+ return ai;
168
+ }
169
+
170
+ export function clearAiConfig(
171
+ configPath: string = LIBRETTO_CONFIG_PATH,
172
+ ): boolean {
173
+ const librettoConfig = readLibrettoConfig(configPath);
174
+ if (!librettoConfig.ai) return false;
175
+ const { ai: _ai, ...rest } = librettoConfig;
176
+ writeLibrettoConfig(
177
+ {
178
+ ...rest,
179
+ },
180
+ configPath,
181
+ );
182
+ return true;
183
+ }
184
+
185
+ function printAiConfig(config: AiConfig, configPath: string): void {
186
+ console.log(`Model: ${config.model}`);
187
+ console.log(`Config file: ${configPath}`);
188
+ console.log(`Updated at: ${config.updatedAt}`);
189
+ }
190
+
191
+ /**
192
+ * Resolve the model string from a `ai configure` argument.
193
+ * Accepts a provider shorthand ("openai", "anthropic", "gemini", "vertex")
194
+ * or a full provider/model-id string ("openai/gpt-4o", "anthropic/claude-sonnet-4-6").
195
+ */
196
+ function resolveModelFromInput(input: string): string | null {
197
+ const trimmed = input.trim();
198
+ if (!trimmed) return null;
199
+
200
+ // Full model string (contains a slash)
201
+ if (trimmed.includes("/")) return trimmed;
202
+
203
+ // Provider shorthand
204
+ const normalized = trimmed.toLowerCase();
205
+ return DEFAULT_MODELS[normalized] ?? PROVIDER_ALIASES[normalized] ?? null;
206
+ }
207
+
208
+ export function runAiConfigure(
209
+ input: {
210
+ preset?: string;
211
+ clear?: boolean;
212
+ },
213
+ options: {
214
+ configureCommandName?: string;
215
+ configPath?: string;
216
+ } = {},
217
+ ): void {
218
+ const configureCommandName =
219
+ options.configureCommandName ?? "npx libretto ai configure";
220
+ const configPath = options.configPath ?? LIBRETTO_CONFIG_PATH;
221
+
222
+ const presetArg = input.preset?.trim();
223
+
224
+ if (!presetArg && !input.clear) {
225
+ const config = readAiConfig(configPath);
226
+ if (!config) {
227
+ console.log(
228
+ `No AI config set. Choose a default model: ${configureCommandName} ${formatConfigureProviders()}`,
229
+ );
230
+ console.log(
231
+ "Provider credentials still come from your shell or .env file.",
232
+ );
233
+ return;
234
+ }
235
+ printAiConfig(config, configPath);
236
+ return;
237
+ }
238
+
239
+ if (input.clear) {
240
+ const removed = clearAiConfig(configPath);
241
+ if (removed) {
242
+ console.log(`Cleared AI config: ${configPath}`);
243
+ } else {
244
+ console.log("No AI config was set.");
245
+ }
246
+ return;
247
+ }
248
+
249
+ const model = resolveModelFromInput(presetArg!);
250
+ if (!model) {
251
+ console.log(
252
+ `Usage: ${configureCommandName} <${CONFIGURE_PROVIDERS.join("|")}|provider/model-id>\n` +
253
+ ` ${configureCommandName}\n` +
254
+ ` ${configureCommandName} --clear`,
255
+ );
256
+ throw new Error(
257
+ `Invalid provider or model. Use one of: ${formatConfigureProviders()}, or a full model string like "openai/gpt-4o".`,
258
+ );
259
+ }
260
+
261
+ const config = writeAiConfig(model, configPath);
262
+ console.log("AI config saved.");
263
+ printAiConfig(config, configPath);
264
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * API-based snapshot analyzer.
3
+ *
4
+ * Sends the DOM snapshot (condensed or full depending on sizing) and screenshot
5
+ * directly to a supported API provider via the Vercel AI SDK, without spawning
6
+ * a CLI process.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import type { LoggerApi } from "../../shared/logger/index.js";
11
+ import { createLLMClient } from "../../shared/llm/client.js";
12
+ import {
13
+ InterpretResultSchema,
14
+ buildInlinePromptSelection,
15
+ getMimeType,
16
+ readFileAsBase64,
17
+ type InterpretResult,
18
+ type InterpretArgs,
19
+ } from "./snapshot-analyzer.js";
20
+ import { readAiConfig, type AiConfig } from "./ai-config.js";
21
+ import { resolveSnapshotApiModelOrThrow } from "./snapshot-api-config.js";
22
+
23
+ export async function runApiInterpret(
24
+ args: InterpretArgs,
25
+ logger: LoggerApi,
26
+ configuredAi: AiConfig | null = readAiConfig(),
27
+ ): Promise<void> {
28
+ const selection = resolveSnapshotApiModelOrThrow(configuredAi);
29
+
30
+ logger.info("api-interpret-start", {
31
+ objective: args.objective,
32
+ pngPath: args.pngPath,
33
+ htmlPath: args.htmlPath,
34
+ condensedHtmlPath: args.condensedHtmlPath,
35
+ model: selection.model,
36
+ modelSource: selection.source,
37
+ });
38
+
39
+ const fullHtmlContent = readFileSync(args.htmlPath, "utf-8");
40
+ const condensedHtmlContent = readFileSync(args.condensedHtmlPath, "utf-8");
41
+
42
+ const promptSelection = buildInlinePromptSelection(
43
+ args,
44
+ fullHtmlContent,
45
+ condensedHtmlContent,
46
+ selection.model,
47
+ );
48
+
49
+ logger.info("api-interpret-dom-selection", {
50
+ configuredModel: promptSelection.stats.configuredModel,
51
+ fullDomEstimatedTokens: promptSelection.stats.fullDomEstimatedTokens,
52
+ condensedDomEstimatedTokens:
53
+ promptSelection.stats.condensedDomEstimatedTokens,
54
+ contextWindowTokens: promptSelection.budget.contextWindowTokens,
55
+ promptBudgetTokens: promptSelection.budget.promptBudgetTokens,
56
+ selectedDom: promptSelection.domSource,
57
+ selectedHtmlEstimatedTokens: promptSelection.htmlEstimatedTokens,
58
+ selectedPromptEstimatedTokens: promptSelection.promptEstimatedTokens,
59
+ selectionReason: promptSelection.selectionReason,
60
+ truncated: promptSelection.truncated,
61
+ });
62
+
63
+ const imageBase64 = readFileAsBase64(args.pngPath);
64
+ const imageMimeType = getMimeType(args.pngPath);
65
+ const imageBytes = Buffer.from(imageBase64, "base64");
66
+
67
+ const client = createLLMClient(selection.model);
68
+
69
+ const result = await client.generateObjectFromMessages({
70
+ schema: InterpretResultSchema,
71
+ messages: [
72
+ {
73
+ role: "user",
74
+ content: [
75
+ { type: "text", text: promptSelection.prompt },
76
+ {
77
+ type: "image",
78
+ image: imageBytes,
79
+ mediaType: imageMimeType,
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ temperature: 0.1,
85
+ });
86
+
87
+ const parsed: InterpretResult = InterpretResultSchema.parse(result);
88
+
89
+ logger.info("api-interpret-success", {
90
+ selectorCount: parsed.selectors.length,
91
+ answer: parsed.answer.slice(0, 200),
92
+ });
93
+
94
+ console.log("");
95
+ console.log("Analysis:");
96
+ console.log(parsed.answer);
97
+ if (parsed.selectors.length > 0) {
98
+ console.log("");
99
+ console.log("Selectors:");
100
+ parsed.selectors.forEach((selector, index) => {
101
+ console.log(` ${index + 1}. ${selector.label}: ${selector.selector}`);
102
+ });
103
+ }
104
+ if (parsed.notes?.trim()) {
105
+ console.log("");
106
+ console.log(`Notes: ${parsed.notes.trim()}`);
107
+ }
108
+ }