libretto 0.3.2 → 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.
@@ -2,16 +2,14 @@ import { runAiConfigure } from "../core/ai-config.js";
2
2
  function registerAICommands(yargs) {
3
3
  return yargs.command(
4
4
  "ai configure [preset]",
5
- "Configure AI runtime",
5
+ "Configure AI model for snapshot analysis",
6
6
  (cmd) => cmd.option("clear", { type: "boolean", default: false }),
7
7
  (argv) => {
8
- const customPrefix = Array.isArray(argv["--"]) ? argv["--"] : [];
9
8
  runAiConfigure({
10
9
  clear: Boolean(argv.clear),
11
- preset: argv.preset,
12
- customPrefix
10
+ preset: argv.preset
13
11
  }, {
14
- configureCommandName: "libretto-cli ai configure"
12
+ configureCommandName: "npx libretto ai configure"
15
13
  });
16
14
  }
17
15
  );
@@ -1,67 +1,169 @@
1
- import { accessSync, constants, statSync } from "node:fs";
2
- import { join, delimiter, extname } from "node:path";
1
+ import { createInterface } from "node:readline";
2
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
+ import { join } from "node:path";
4
5
  import {
5
- AI_CONFIG_PRESETS,
6
- AiPresetSchema,
7
- formatCommandPrefix,
8
6
  readAiConfig
9
7
  } from "../core/ai-config.js";
10
- const AI_RUNTIME_PRESETS = AiPresetSchema.options;
11
- function getPresetCommand(preset) {
12
- return AI_CONFIG_PRESETS[preset][0] ?? "";
8
+ import { REPO_ROOT } from "../core/context.js";
9
+ import {
10
+ loadSnapshotEnv,
11
+ resolveSnapshotApiModel
12
+ } from "../core/snapshot-api-config.js";
13
+ import { hasProviderCredentials } from "../../shared/llm/client.js";
14
+ const PROVIDER_CHOICES = [
15
+ {
16
+ key: "1",
17
+ label: "OpenAI",
18
+ envVar: "OPENAI_API_KEY",
19
+ envHint: "Get your key at https://platform.openai.com/api-keys"
20
+ },
21
+ {
22
+ key: "2",
23
+ label: "Anthropic",
24
+ envVar: "ANTHROPIC_API_KEY",
25
+ envHint: "Get your key at https://console.anthropic.com/settings/keys"
26
+ },
27
+ {
28
+ key: "3",
29
+ label: "Google Gemini",
30
+ envVar: "GEMINI_API_KEY",
31
+ envHint: "Get your key at https://aistudio.google.com/apikey"
32
+ },
33
+ {
34
+ key: "4",
35
+ label: "Google Vertex AI",
36
+ envVar: "GOOGLE_CLOUD_PROJECT",
37
+ envHint: "Requires gcloud auth application-default login and a GCP project ID"
38
+ }
39
+ ];
40
+ function promptUser(rl, question) {
41
+ return new Promise((resolve) => {
42
+ rl.question(question, (answer) => {
43
+ resolve(answer.trim());
44
+ });
45
+ });
13
46
  }
14
- function isRunnableFile(filePath) {
47
+ function safeReadAiConfig() {
15
48
  try {
16
- const stats = statSync(filePath);
17
- if (!stats.isFile()) return false;
18
- if (process.platform === "win32") {
19
- const pathExt = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
20
- const extensions = pathExt.split(";").map((ext) => ext.trim().toUpperCase()).filter(Boolean);
21
- const fileExt = extname(filePath).toUpperCase();
22
- return extensions.includes(fileExt);
23
- }
24
- accessSync(filePath, constants.X_OK);
25
- return true;
49
+ return readAiConfig();
26
50
  } catch {
27
- return false;
51
+ return null;
28
52
  }
29
53
  }
30
- function isCommandDefined(command) {
31
- if (!command) return false;
32
- if (command.includes("/") || command.includes("\\")) {
33
- return isRunnableFile(command);
34
- }
35
- const pathEnv = process.env.PATH ?? "";
36
- if (!pathEnv) return false;
37
- const pathEntries = pathEnv.split(delimiter).filter(Boolean);
38
- if (process.platform === "win32") {
39
- const pathExt = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
40
- const extensions = pathExt.split(";").map((ext) => ext.trim()).filter(Boolean);
41
- const hasExtension = /\.[^./\\]+$/.test(command);
42
- const candidates = hasExtension ? [command] : extensions.map(
43
- (ext) => ext.startsWith(".") ? `${command}${ext}` : `${command}.${ext}`
44
- );
45
- return pathEntries.some(
46
- (dir) => candidates.some((candidate) => isRunnableFile(join(dir, candidate)))
54
+ function printSnapshotApiStatus() {
55
+ const config = safeReadAiConfig();
56
+ const selection = resolveSnapshotApiModel(config);
57
+ const envPath = join(REPO_ROOT, ".env");
58
+ console.log("\nSnapshot analysis:");
59
+ console.log(
60
+ " Libretto uses direct API calls for snapshot analysis when supported credentials are available."
61
+ );
62
+ console.log(` Credentials are loaded from process env and ${envPath}.`);
63
+ if (selection && hasProviderCredentials(selection.provider)) {
64
+ console.log(
65
+ ` \u2713 Ready: ${selection.model} (${selection.source})`
47
66
  );
67
+ console.log(" Snapshot objectives will use the API analyzer by default.");
68
+ console.log(" No further action required.");
69
+ return;
48
70
  }
49
- return pathEntries.some((dir) => isRunnableFile(join(dir, command)));
50
- }
51
- function detectAvailableAiRuntimeCommands() {
52
- return AI_RUNTIME_PRESETS.filter(
53
- (preset) => isCommandDefined(getPresetCommand(preset))
71
+ console.log(" \u2717 No snapshot API credentials detected.");
72
+ console.log(" Add one provider to .env:");
73
+ console.log(" OPENAI_API_KEY=...");
74
+ console.log(" ANTHROPIC_API_KEY=...");
75
+ console.log(" GEMINI_API_KEY=... # or GOOGLE_GENERATIVE_AI_API_KEY");
76
+ console.log(
77
+ " GOOGLE_CLOUD_PROJECT=... # plus application default credentials for Vertex"
54
78
  );
79
+ console.log(
80
+ " Or run `npx libretto ai configure <provider>` to set a specific model."
81
+ );
82
+ console.log(" Run `npx libretto init` interactively to set up credentials.");
55
83
  }
56
- function printAiConfigureCommands(prefix = " ") {
57
- for (const preset of AI_RUNTIME_PRESETS) {
58
- console.log(`${prefix}npx libretto ai configure ${preset}`);
59
- }
60
- }
61
- function printDifferentAnalyzerHint(prefix = " ") {
84
+ async function runInteractiveApiSetup() {
85
+ const config = safeReadAiConfig();
86
+ const selection = resolveSnapshotApiModel(config);
87
+ const envPath = join(REPO_ROOT, ".env");
88
+ console.log("\nSnapshot analysis setup:");
62
89
  console.log(
63
- `${prefix}Use npx libretto ai configure <gemini|claude|codex> to configure a different AI analyzer.`
90
+ " Libretto uses direct API calls for snapshot analysis."
64
91
  );
92
+ console.log(` Credentials are loaded from process env and ${envPath}.`);
93
+ if (selection && hasProviderCredentials(selection.provider)) {
94
+ console.log(
95
+ ` \u2713 Ready: ${selection.model} (${selection.source})`
96
+ );
97
+ console.log(" Snapshot objectives will use the API analyzer by default.");
98
+ return;
99
+ }
100
+ console.log(" \u2717 No snapshot API credentials detected.\n");
101
+ const rl = createInterface({
102
+ input: process.stdin,
103
+ output: process.stdout
104
+ });
105
+ try {
106
+ console.log(" Which API provider would you like to use for snapshot analysis?\n");
107
+ for (const choice of PROVIDER_CHOICES) {
108
+ console.log(` ${choice.key}) ${choice.label}`);
109
+ }
110
+ console.log(" s) Skip for now\n");
111
+ const answer = await promptUser(rl, " Choice: ");
112
+ if (answer.toLowerCase() === "s" || !answer) {
113
+ console.log("\n Skipped. You can set up API credentials later by rerunning `npx libretto init`.");
114
+ console.log(" Or add credentials directly to your .env file:");
115
+ console.log(" OPENAI_API_KEY=...");
116
+ console.log(" ANTHROPIC_API_KEY=...");
117
+ console.log(" GEMINI_API_KEY=...");
118
+ console.log(
119
+ " Or run `npx libretto ai configure <provider>` to set a specific model."
120
+ );
121
+ return;
122
+ }
123
+ const selected = PROVIDER_CHOICES.find((c) => c.key === answer);
124
+ if (!selected) {
125
+ console.log(`
126
+ Unknown choice "${answer}". Skipping API setup.`);
127
+ return;
128
+ }
129
+ console.log(`
130
+ ${selected.label} selected.`);
131
+ console.log(` ${selected.envHint}
132
+ `);
133
+ const apiKeyValue = await promptUser(rl, ` Enter your ${selected.envVar}: `);
134
+ if (!apiKeyValue) {
135
+ console.log("\n No value entered. Skipping API key setup.");
136
+ return;
137
+ }
138
+ let envContent = "";
139
+ if (existsSync(envPath)) {
140
+ envContent = readFileSync(envPath, "utf-8");
141
+ }
142
+ const envLine = `${selected.envVar}=${apiKeyValue}`;
143
+ if (envContent.includes(`${selected.envVar}=`)) {
144
+ const updated = envContent.replace(
145
+ new RegExp(`^${selected.envVar}=.*$`, "m"),
146
+ () => envLine
147
+ );
148
+ writeFileSync(envPath, updated);
149
+ console.log(`
150
+ \u2713 Updated ${selected.envVar} in ${envPath}`);
151
+ } else {
152
+ const separator = envContent && !envContent.endsWith("\n") ? "\n" : "";
153
+ appendFileSync(envPath, `${separator}${envLine}
154
+ `);
155
+ console.log(`
156
+ \u2713 Added ${selected.envVar} to ${envPath}`);
157
+ }
158
+ loadSnapshotEnv();
159
+ process.env[selected.envVar] = apiKeyValue;
160
+ const newSelection = resolveSnapshotApiModel(safeReadAiConfig());
161
+ if (newSelection && hasProviderCredentials(newSelection.provider)) {
162
+ console.log(` \u2713 Snapshot API ready: ${newSelection.model}`);
163
+ }
164
+ } finally {
165
+ rl.close();
166
+ }
65
167
  }
66
168
  function installBrowsers() {
67
169
  console.log("\nInstalling Playwright Chromium...");
@@ -77,69 +179,6 @@ function installBrowsers() {
77
179
  );
78
180
  }
79
181
  }
80
- function checkAiRuntimeConfiguration() {
81
- let config = null;
82
- let configReadError = null;
83
- try {
84
- config = readAiConfig();
85
- } catch (error) {
86
- configReadError = error instanceof Error ? error.message : String(error);
87
- }
88
- const availableCommands = detectAvailableAiRuntimeCommands();
89
- console.log("\nAI runtime configuration:");
90
- console.log(
91
- " Libretto can use your coding agent as a subagent to analyze snapshots and other page signals."
92
- );
93
- console.log(
94
- " This is optional, but it significantly improves page understanding and debugging performance."
95
- );
96
- if (configReadError) {
97
- console.log(` \u2717 Could not read AI config: ${configReadError}`);
98
- console.log(" Reconfigure with:");
99
- printAiConfigureCommands(" ");
100
- printDifferentAnalyzerHint(" ");
101
- return;
102
- }
103
- if (config) {
104
- const configuredCommand = config.commandPrefix[0];
105
- if (!isCommandDefined(configuredCommand)) {
106
- console.log(
107
- ` \u2717 Configured command not found: ${configuredCommand ?? "(empty)"}`
108
- );
109
- if (availableCommands.length > 0) {
110
- console.log(
111
- ` Detected available commands: ${availableCommands.join(", ")}`
112
- );
113
- } else {
114
- console.log(
115
- " No codex, claude, or gemini analyzer command was detected on PATH."
116
- );
117
- }
118
- console.log(" Reconfigure with:");
119
- printAiConfigureCommands(" ");
120
- printDifferentAnalyzerHint(" ");
121
- return;
122
- }
123
- console.log(
124
- ` \u2713 Configured (${config.preset}): ${formatCommandPrefix(config.commandPrefix)}`
125
- );
126
- console.log(" Analysis commands are ready to use.");
127
- printDifferentAnalyzerHint(" ");
128
- return;
129
- }
130
- console.log(" \u2717 No AI config set.");
131
- if (availableCommands.length > 0) {
132
- console.log(
133
- ` Detected available commands: ${availableCommands.join(", ")}`
134
- );
135
- } else {
136
- console.log(" No codex, claude, or gemini analyzer command was detected on PATH.");
137
- }
138
- console.log(" Configure one with:");
139
- printAiConfigureCommands(" ");
140
- printDifferentAnalyzerHint(" ");
141
- console.log(" Optionally provide a custom command prefix with '-- ...'.");
142
- }
143
182
  function registerInitCommand(yargs) {
144
183
  return yargs.command(
145
184
  "init",
@@ -149,14 +188,18 @@ function registerInitCommand(yargs) {
149
188
  default: false,
150
189
  describe: "Skip Playwright Chromium installation"
151
190
  }),
152
- (argv) => {
191
+ async (argv) => {
153
192
  console.log("Initializing libretto...\n");
154
193
  if (!argv["skip-browsers"]) {
155
194
  installBrowsers();
156
195
  } else {
157
196
  console.log("\nSkipping browser installation (--skip-browsers)");
158
197
  }
159
- checkAiRuntimeConfiguration();
198
+ if (process.stdin.isTTY) {
199
+ await runInteractiveApiSetup();
200
+ } else {
201
+ printSnapshotApiStatus();
202
+ }
160
203
  console.log("\n\u2713 libretto init complete");
161
204
  }
162
205
  );
@@ -1,11 +1,10 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { connect, disconnectBrowser } from "../core/browser.js";
3
3
  import { getSessionSnapshotRunDir } from "../core/context.js";
4
+ import { condenseDom } from "../../shared/condense-dom/condense-dom.js";
4
5
  import { readSessionState } from "../core/session.js";
5
- import {
6
- canAnalyzeSnapshots,
7
- runInterpret
8
- } from "../core/snapshot-analyzer.js";
6
+ import { runApiInterpret } from "../core/api-snapshot-analyzer.js";
7
+ import { readAiConfig } from "../core/ai-config.js";
9
8
  const DEFAULT_SNAPSHOT_CONTEXT = "No additional user context provided.";
10
9
  const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 };
11
10
  function generateSnapshotRunId() {
@@ -97,6 +96,7 @@ async function captureScreenshot(session, logger, pageId) {
97
96
  }
98
97
  const pngPath = `${snapshotRunDir}/page.png`;
99
98
  const htmlPath = `${snapshotRunDir}/page.html`;
99
+ const condensedHtmlPath = `${snapshotRunDir}/page.condensed.html`;
100
100
  const restoreViewport = resolveSnapshotViewport(session, logger);
101
101
  const viewportMetrics = await readSnapshotViewportMetrics(page);
102
102
  logger.info("screenshot-viewport-metrics", {
@@ -132,15 +132,23 @@ async function captureScreenshot(session, logger, pageId) {
132
132
  const htmlContent = await page.content();
133
133
  const fs = await import("node:fs/promises");
134
134
  await fs.writeFile(htmlPath, htmlContent);
135
+ const condenseResult = condenseDom(htmlContent);
136
+ await fs.writeFile(condensedHtmlPath, condenseResult.html);
135
137
  logger.info("screenshot-success", {
136
138
  session,
137
139
  pageUrl,
138
140
  title,
139
141
  pngPath,
140
142
  htmlPath,
141
- snapshotRunId
143
+ condensedHtmlPath,
144
+ snapshotRunId,
145
+ domCondenseStats: {
146
+ originalLength: condenseResult.originalLength,
147
+ condensedLength: condenseResult.condensedLength,
148
+ reductions: condenseResult.reductions
149
+ }
142
150
  });
143
- return { pngPath, htmlPath, baseName: snapshotRunId };
151
+ return { pngPath, htmlPath, condensedHtmlPath, baseName: snapshotRunId };
144
152
  } catch (err) {
145
153
  let pageAlive = false;
146
154
  let browserConnected = false;
@@ -168,33 +176,35 @@ async function captureScreenshot(session, logger, pageId) {
168
176
  }
169
177
  }
170
178
  async function runSnapshot(session, logger, pageId, objective, context) {
171
- const { pngPath, htmlPath } = await captureScreenshot(session, logger, pageId);
172
- console.log("Screenshot saved:");
173
- console.log(` PNG: ${pngPath}`);
174
- console.log(` HTML: ${htmlPath}`);
175
179
  const normalizedObjective = objective?.trim();
176
180
  const normalizedContext = context?.trim();
177
- if (!normalizedObjective && !normalizedContext) {
178
- console.log("Use --objective flag to analyze snapshots.");
179
- return;
180
- }
181
- if (!normalizedObjective) {
181
+ if (!normalizedObjective && normalizedContext) {
182
182
  throw new Error(
183
183
  "Couldn't run analysis: --objective is required when providing --context."
184
184
  );
185
185
  }
186
- if (!canAnalyzeSnapshots()) {
187
- throw new Error(
188
- "Couldn't run analysis: no AI config set. Run 'libretto-cli ai configure codex' (or claude/gemini) to enable analysis."
189
- );
186
+ const { pngPath, htmlPath, condensedHtmlPath } = await captureScreenshot(
187
+ session,
188
+ logger,
189
+ pageId
190
+ );
191
+ console.log("Screenshot saved:");
192
+ console.log(` PNG: ${pngPath}`);
193
+ console.log(` HTML: ${htmlPath}`);
194
+ console.log(` Condensed HTML: ${condensedHtmlPath}`);
195
+ if (!normalizedObjective) {
196
+ console.log("Use --objective flag to analyze snapshots.");
197
+ return;
190
198
  }
191
- await runInterpret({
199
+ const interpretArgs = {
192
200
  objective: normalizedObjective,
193
201
  session,
194
202
  context: normalizedContext ?? DEFAULT_SNAPSHOT_CONTEXT,
195
203
  pngPath,
196
- htmlPath
197
- }, logger);
204
+ htmlPath,
205
+ condensedHtmlPath
206
+ };
207
+ await runApiInterpret(interpretArgs, logger, readAiConfig());
198
208
  }
199
209
  function registerSnapshotCommands(yargs, logger) {
200
210
  return yargs.command(
@@ -1,13 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
2
+ import { dirname } from "node:path";
4
3
  import { z } from "zod";
5
4
  import { LIBRETTO_CONFIG_PATH } from "./context.js";
6
5
  const CURRENT_CONFIG_VERSION = 1;
7
- const AiPresetSchema = z.enum(["codex", "claude", "gemini"]);
8
6
  const AiConfigSchema = z.object({
9
- preset: AiPresetSchema,
10
- commandPrefix: z.array(z.string()).min(1),
7
+ model: z.string().min(1),
11
8
  updatedAt: z.string()
12
9
  }).strict();
13
10
  const ViewportConfigSchema = z.object({
@@ -19,11 +16,14 @@ const LibrettoConfigSchema = z.object({
19
16
  ai: AiConfigSchema.optional(),
20
17
  viewport: ViewportConfigSchema.optional()
21
18
  }).passthrough();
22
- const AI_CONFIG_PRESETS = {
23
- codex: ["codex", "exec", "--skip-git-repo-check", "--sandbox", "read-only"],
24
- claude: [join(homedir(), ".claude", "local", "claude"), "-p"],
25
- gemini: ["gemini", "--output-format", "json"]
19
+ const DEFAULT_MODELS = {
20
+ openai: "openai/gpt-5.4",
21
+ anthropic: "anthropic/claude-sonnet-4-6",
22
+ gemini: "google/gemini-2.5-flash",
23
+ google: "google/gemini-2.5-flash",
24
+ vertex: "vertex/gemini-2.5-pro"
26
25
  };
26
+ const CONFIGURE_PROVIDERS = Object.keys(DEFAULT_MODELS);
27
27
  function invalidConfigError(configPath) {
28
28
  return new Error(
29
29
  `AI config is invalid at ${configPath}. Fix the file to match the expected schema or delete it.`
@@ -51,18 +51,10 @@ function writeLibrettoConfig(config, configPath = LIBRETTO_CONFIG_PATH) {
51
51
  function readAiConfig(configPath = LIBRETTO_CONFIG_PATH) {
52
52
  return readLibrettoConfig(configPath).ai ?? null;
53
53
  }
54
- function quoteShellArg(value) {
55
- if (/^[a-zA-Z0-9_./:@=-]+$/.test(value)) return value;
56
- return JSON.stringify(value);
57
- }
58
- function formatCommandPrefix(prefix) {
59
- return prefix.map((arg) => quoteShellArg(arg)).join(" ");
60
- }
61
- function writeAiConfig(preset, commandPrefix, configPath = LIBRETTO_CONFIG_PATH) {
54
+ function writeAiConfig(model, configPath = LIBRETTO_CONFIG_PATH) {
62
55
  const librettoConfig = readLibrettoConfig(configPath);
63
56
  const ai = AiConfigSchema.parse({
64
- preset,
65
- commandPrefix,
57
+ model,
66
58
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
67
59
  });
68
60
  writeLibrettoConfig(
@@ -88,27 +80,24 @@ function clearAiConfig(configPath = LIBRETTO_CONFIG_PATH) {
88
80
  return true;
89
81
  }
90
82
  function printAiConfig(config, configPath) {
91
- console.log(`AI preset: ${config.preset}`);
92
- console.log(`Command prefix: ${formatCommandPrefix(config.commandPrefix)}`);
83
+ console.log(`Model: ${config.model}`);
93
84
  console.log(`Config file: ${configPath}`);
94
85
  console.log(`Updated at: ${config.updatedAt}`);
95
86
  }
96
- function printConfigureUsage(commandName) {
97
- console.log(
98
- `Usage: ${commandName} <codex|claude|gemini> [-- <command prefix...>]
99
- ${commandName}
100
- ${commandName} --clear`
101
- );
87
+ function resolveModelFromInput(input) {
88
+ const trimmed = input.trim();
89
+ if (!trimmed) return null;
90
+ if (trimmed.includes("/")) return trimmed;
91
+ return DEFAULT_MODELS[trimmed.toLowerCase()] ?? null;
102
92
  }
103
93
  function runAiConfigure(input, options = {}) {
104
- const configureCommandName = options.configureCommandName ?? "libretto-cli ai configure";
94
+ const configureCommandName = options.configureCommandName ?? "npx libretto ai configure";
105
95
  const configPath = options.configPath ?? LIBRETTO_CONFIG_PATH;
106
96
  const presetArg = input.preset?.trim();
107
- const customPrefix = (input.customPrefix ?? []).filter(Boolean);
108
- if (!presetArg && customPrefix.length === 0 && !input.clear) {
97
+ if (!presetArg && !input.clear) {
109
98
  const config2 = readAiConfig(configPath);
110
99
  if (!config2) {
111
- console.log(`No AI config set. Run '${configureCommandName} codex' to set one.`);
100
+ console.log(`No AI config set. Run '${configureCommandName} openai' to set one.`);
112
101
  return;
113
102
  }
114
103
  printAiConfig(config2, configPath);
@@ -123,31 +112,27 @@ function runAiConfigure(input, options = {}) {
123
112
  }
124
113
  return;
125
114
  }
126
- const parsedPreset = AiPresetSchema.safeParse(presetArg);
127
- if (!parsedPreset.success) {
128
- printConfigureUsage(configureCommandName);
115
+ const model = resolveModelFromInput(presetArg);
116
+ if (!model) {
117
+ console.log(
118
+ `Usage: ${configureCommandName} <${CONFIGURE_PROVIDERS.join("|")}|provider/model-id>
119
+ ${configureCommandName}
120
+ ${configureCommandName} --clear`
121
+ );
129
122
  throw new Error(
130
- "Missing or invalid preset. Use one of: codex, claude, gemini."
123
+ `Invalid provider or model. Use one of: ${CONFIGURE_PROVIDERS.join(", ")}, or a full model string like "openai/gpt-4o".`
131
124
  );
132
125
  }
133
- if (input.customPrefix && input.customPrefix.length > 0 && customPrefix.length === 0) {
134
- throw new Error("Custom command prefix cannot be empty.");
135
- }
136
- const preset = parsedPreset.data;
137
- const commandPrefix = customPrefix.length > 0 ? customPrefix : AI_CONFIG_PRESETS[preset];
138
- const config = writeAiConfig(preset, commandPrefix, configPath);
126
+ const config = writeAiConfig(model, configPath);
139
127
  console.log("AI config saved.");
140
128
  printAiConfig(config, configPath);
141
129
  }
142
130
  export {
143
- AI_CONFIG_PRESETS,
144
131
  AiConfigSchema,
145
- AiPresetSchema,
146
132
  CURRENT_CONFIG_VERSION,
147
133
  LibrettoConfigSchema,
148
134
  ViewportConfigSchema,
149
135
  clearAiConfig,
150
- formatCommandPrefix,
151
136
  readAiConfig,
152
137
  readLibrettoConfig,
153
138
  runAiConfigure,
@@ -0,0 +1,74 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createLLMClient } from "../../shared/llm/client.js";
3
+ import {
4
+ formatInterpretationOutput,
5
+ InterpretResultSchema,
6
+ buildInlinePromptSelection,
7
+ getMimeType,
8
+ readFileAsBase64
9
+ } from "./snapshot-analyzer.js";
10
+ import { readAiConfig } from "./ai-config.js";
11
+ import {
12
+ resolveSnapshotApiModelOrThrow
13
+ } from "./snapshot-api-config.js";
14
+ async function runApiInterpret(args, logger, configuredAi = readAiConfig()) {
15
+ const selection = resolveSnapshotApiModelOrThrow(configuredAi);
16
+ logger.info("api-interpret-start", {
17
+ objective: args.objective,
18
+ pngPath: args.pngPath,
19
+ htmlPath: args.htmlPath,
20
+ condensedHtmlPath: args.condensedHtmlPath,
21
+ model: selection.model,
22
+ modelSource: selection.source
23
+ });
24
+ const fullHtmlContent = readFileSync(args.htmlPath, "utf-8");
25
+ const condensedHtmlContent = readFileSync(args.condensedHtmlPath, "utf-8");
26
+ const promptSelection = buildInlinePromptSelection(
27
+ args,
28
+ fullHtmlContent,
29
+ condensedHtmlContent,
30
+ selection.model
31
+ );
32
+ logger.info("api-interpret-dom-selection", {
33
+ configuredModel: promptSelection.stats.configuredModel,
34
+ fullDomEstimatedTokens: promptSelection.stats.fullDomEstimatedTokens,
35
+ condensedDomEstimatedTokens: promptSelection.stats.condensedDomEstimatedTokens,
36
+ contextWindowTokens: promptSelection.budget.contextWindowTokens,
37
+ promptBudgetTokens: promptSelection.budget.promptBudgetTokens,
38
+ selectedDom: promptSelection.domSource,
39
+ selectedHtmlEstimatedTokens: promptSelection.htmlEstimatedTokens,
40
+ selectedPromptEstimatedTokens: promptSelection.promptEstimatedTokens,
41
+ selectionReason: promptSelection.selectionReason,
42
+ truncated: promptSelection.truncated
43
+ });
44
+ const imageBase64 = readFileAsBase64(args.pngPath);
45
+ const imageMimeType = getMimeType(args.pngPath);
46
+ const imageBytes = Buffer.from(imageBase64, "base64");
47
+ const client = createLLMClient(selection.model);
48
+ const result = await client.generateObjectFromMessages({
49
+ schema: InterpretResultSchema,
50
+ messages: [
51
+ {
52
+ role: "user",
53
+ content: [
54
+ { type: "text", text: promptSelection.prompt },
55
+ {
56
+ type: "image",
57
+ image: imageBytes,
58
+ mediaType: imageMimeType
59
+ }
60
+ ]
61
+ }
62
+ ],
63
+ temperature: 0.1
64
+ });
65
+ const parsed = InterpretResultSchema.parse(result);
66
+ logger.info("api-interpret-success", {
67
+ selectorCount: parsed.selectors.length,
68
+ answer: parsed.answer.slice(0, 200)
69
+ });
70
+ console.log(formatInterpretationOutput(parsed, "Interpretation (via API):"));
71
+ }
72
+ export {
73
+ runApiInterpret
74
+ };
@@ -86,7 +86,7 @@ function getLLMClientFactory() {
86
86
  }
87
87
  function maybeConfigureLLMClientFactoryFromEnv() {
88
88
  if (llmClientFactory) return;
89
- const hasAnyCreds = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
89
+ const hasAnyCreds = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
90
90
  if (!hasAnyCreds) return;
91
91
  setLLMClientFactory(async (_logger, model) => {
92
92
  const { createLLMClient } = await import("../../shared/llm/index.js");