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