libretto 0.3.1 → 0.3.2
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/browser.js +23 -2
- package/dist/cli/commands/snapshot.js +115 -4
- package/dist/cli/core/ai-config.js +9 -2
- package/dist/cli/core/browser.js +21 -4
- package/dist/cli/core/snapshot-analyzer.js +96 -18
- package/dist/cli/index.js +1 -0
- package/dist/shared/llm/types.d.cts +2 -2
- package/dist/shared/llm/types.d.ts +2 -2
- 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 +34 -33
package/README.md
CHANGED
|
@@ -6,21 +6,18 @@ It is designed for engineering teams that automate workflows in web apps and wan
|
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
-
Install Libretto in your project with your favorite package manager:
|
|
10
|
-
|
|
11
9
|
```bash
|
|
12
|
-
npm install libretto
|
|
13
|
-
yarn add libretto playwright zod
|
|
14
|
-
bun add libretto playwright zod
|
|
15
|
-
pnpm add libretto playwright zod
|
|
10
|
+
npm install --save-dev libretto
|
|
16
11
|
```
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
Chromium is downloaded automatically via a `postinstall` script. If postinstall scripts are disabled (e.g. `--ignore-scripts`, common in monorepos), run init manually:
|
|
19
14
|
|
|
20
15
|
```bash
|
|
21
16
|
npx libretto init
|
|
22
17
|
```
|
|
23
18
|
|
|
19
|
+
This installs the Chromium browser binary and optionally configures an AI subagent (Gemini, Claude, or Codex) that can analyze page snapshots without consuming the coding agent's context window.
|
|
20
|
+
|
|
24
21
|
## Usage
|
|
25
22
|
|
|
26
23
|
Libretto is usually used through prompts with the Libretto skill.
|
|
@@ -56,6 +53,19 @@ npx libretto help
|
|
|
56
53
|
npx libretto run ./integration.ts main
|
|
57
54
|
```
|
|
58
55
|
|
|
56
|
+
## The `.libretto/` directory
|
|
57
|
+
|
|
58
|
+
Libretto stores local runtime state in a `.libretto/` directory at your project root. Sensitive directories (`sessions/` and `profiles/`) are automatically git-ignored via `.libretto/.gitignore`.
|
|
59
|
+
|
|
60
|
+
- **`profiles/<domain>.json`** — Saved browser sessions (cookies, localStorage) for authenticated sites. Created via `npx libretto save <domain>`. Machine-local and never committed.
|
|
61
|
+
- **`sessions/<name>/`** — Per-session runtime state:
|
|
62
|
+
- `state.json` — Session metadata (debug port, PID, status)
|
|
63
|
+
- `logs.jsonl` — Structured session logs
|
|
64
|
+
- `network.jsonl` — Captured network requests (URLs, methods, headers, response status)
|
|
65
|
+
- `actions.jsonl` — Recorded user actions (clicks, fills, navigations)
|
|
66
|
+
- `snapshots/` — Screenshot PNGs and HTML snapshots captured via `npx libretto snapshot`
|
|
67
|
+
- **`ai.json`** — AI runtime configuration set via `npx libretto ai configure`.
|
|
68
|
+
|
|
59
69
|
## Authors
|
|
60
70
|
|
|
61
71
|
Maintained by the team at [Saffron Health](https://saffron.health).
|
|
@@ -16,6 +16,9 @@ function registerBrowserCommands(yargs, logger) {
|
|
|
16
16
|
}).option("headless", {
|
|
17
17
|
type: "boolean",
|
|
18
18
|
default: false
|
|
19
|
+
}).option("viewport", {
|
|
20
|
+
type: "string",
|
|
21
|
+
describe: "Viewport size as WIDTHxHEIGHT (e.g. 1920x1080)"
|
|
19
22
|
}),
|
|
20
23
|
async (argv) => {
|
|
21
24
|
const hasHeadedFlag = Boolean(argv.headed);
|
|
@@ -27,10 +30,28 @@ function registerBrowserCommands(yargs, logger) {
|
|
|
27
30
|
const url = argv.url;
|
|
28
31
|
if (!url) {
|
|
29
32
|
throw new Error(
|
|
30
|
-
"Usage: libretto-cli open <url> [--headless] [--session <name>]"
|
|
33
|
+
"Usage: libretto-cli open <url> [--headless] [--viewport WxH] [--session <name>]"
|
|
31
34
|
);
|
|
32
35
|
}
|
|
33
|
-
|
|
36
|
+
const viewportArg = argv.viewport;
|
|
37
|
+
let viewport;
|
|
38
|
+
if (viewportArg) {
|
|
39
|
+
const match = viewportArg.match(/^(\d+)x(\d+)$/i);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Invalid --viewport format. Expected WIDTHxHEIGHT (e.g. 1920x1080)."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const w = Number(match[1]);
|
|
46
|
+
const h = Number(match[2]);
|
|
47
|
+
if (w < 1 || h < 1) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Invalid --viewport dimensions. Width and height must be at least 1."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
viewport = { width: w, height: h };
|
|
53
|
+
}
|
|
54
|
+
await runOpen(url, headed, String(argv.session), logger, { viewport });
|
|
34
55
|
}
|
|
35
56
|
).command(
|
|
36
57
|
"save [urlOrDomain]",
|
|
@@ -1,14 +1,70 @@
|
|
|
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 { readSessionState } from "../core/session.js";
|
|
4
5
|
import {
|
|
5
6
|
canAnalyzeSnapshots,
|
|
6
7
|
runInterpret
|
|
7
8
|
} from "../core/snapshot-analyzer.js";
|
|
8
9
|
const DEFAULT_SNAPSHOT_CONTEXT = "No additional user context provided.";
|
|
10
|
+
const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 };
|
|
9
11
|
function generateSnapshotRunId() {
|
|
10
12
|
return `snapshot-${Date.now()}`;
|
|
11
13
|
}
|
|
14
|
+
function isZeroViewport(value) {
|
|
15
|
+
return typeof value === "number" && value <= 0;
|
|
16
|
+
}
|
|
17
|
+
function shouldForceSnapshotViewport(metrics) {
|
|
18
|
+
return isZeroViewport(metrics.configuredWidth) || isZeroViewport(metrics.configuredHeight) || isZeroViewport(metrics.innerWidth) || isZeroViewport(metrics.innerHeight);
|
|
19
|
+
}
|
|
20
|
+
function isZeroWidthScreenshotError(error) {
|
|
21
|
+
return error instanceof Error && error.message.includes("Cannot take screenshot with 0 width");
|
|
22
|
+
}
|
|
23
|
+
async function readSnapshotViewportMetrics(page) {
|
|
24
|
+
const configuredViewport = page.viewportSize();
|
|
25
|
+
let innerWidth = null;
|
|
26
|
+
let innerHeight = null;
|
|
27
|
+
try {
|
|
28
|
+
const innerViewport = await page.evaluate(() => ({
|
|
29
|
+
width: window.innerWidth,
|
|
30
|
+
height: window.innerHeight
|
|
31
|
+
}));
|
|
32
|
+
innerWidth = innerViewport.width;
|
|
33
|
+
innerHeight = innerViewport.height;
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
configuredWidth: configuredViewport?.width ?? null,
|
|
38
|
+
configuredHeight: configuredViewport?.height ?? null,
|
|
39
|
+
innerWidth,
|
|
40
|
+
innerHeight
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function resolveSnapshotViewport(session, logger) {
|
|
44
|
+
const state = readSessionState(session, logger);
|
|
45
|
+
if (state?.viewport) {
|
|
46
|
+
logger.info("screenshot-viewport-from-session-state", {
|
|
47
|
+
session,
|
|
48
|
+
viewport: state.viewport
|
|
49
|
+
});
|
|
50
|
+
return state.viewport;
|
|
51
|
+
}
|
|
52
|
+
logger.info("screenshot-viewport-fallback", {
|
|
53
|
+
session,
|
|
54
|
+
reason: "no viewport in session state",
|
|
55
|
+
viewport: FALLBACK_SNAPSHOT_VIEWPORT
|
|
56
|
+
});
|
|
57
|
+
return FALLBACK_SNAPSHOT_VIEWPORT;
|
|
58
|
+
}
|
|
59
|
+
async function forceSnapshotViewport(page, viewport, logger, session, pageId, reason) {
|
|
60
|
+
await page.setViewportSize(viewport);
|
|
61
|
+
logger.warn("screenshot-viewport-forced", {
|
|
62
|
+
session,
|
|
63
|
+
pageId,
|
|
64
|
+
reason,
|
|
65
|
+
viewport
|
|
66
|
+
});
|
|
67
|
+
}
|
|
12
68
|
async function captureScreenshot(session, logger, pageId) {
|
|
13
69
|
logger.info("screenshot-start", { session, pageId });
|
|
14
70
|
const snapshotRunId = generateSnapshotRunId();
|
|
@@ -19,11 +75,60 @@ async function captureScreenshot(session, logger, pageId) {
|
|
|
19
75
|
requireSinglePage: true
|
|
20
76
|
});
|
|
21
77
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
78
|
+
let title = null;
|
|
79
|
+
try {
|
|
80
|
+
title = await page.title();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.warn("screenshot-title-read-failed", {
|
|
83
|
+
session,
|
|
84
|
+
pageId,
|
|
85
|
+
error
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
let pageUrl = null;
|
|
89
|
+
try {
|
|
90
|
+
pageUrl = page.url();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.warn("screenshot-url-read-failed", {
|
|
93
|
+
session,
|
|
94
|
+
pageId,
|
|
95
|
+
error
|
|
96
|
+
});
|
|
97
|
+
}
|
|
24
98
|
const pngPath = `${snapshotRunDir}/page.png`;
|
|
25
99
|
const htmlPath = `${snapshotRunDir}/page.html`;
|
|
26
|
-
|
|
100
|
+
const restoreViewport = resolveSnapshotViewport(session, logger);
|
|
101
|
+
const viewportMetrics = await readSnapshotViewportMetrics(page);
|
|
102
|
+
logger.info("screenshot-viewport-metrics", {
|
|
103
|
+
session,
|
|
104
|
+
pageId,
|
|
105
|
+
restoreViewport,
|
|
106
|
+
...viewportMetrics
|
|
107
|
+
});
|
|
108
|
+
await forceSnapshotViewport(
|
|
109
|
+
page,
|
|
110
|
+
restoreViewport,
|
|
111
|
+
logger,
|
|
112
|
+
session,
|
|
113
|
+
pageId,
|
|
114
|
+
shouldForceSnapshotViewport(viewportMetrics) ? "preflight-invalid-viewport" : "preflight-normalize-viewport"
|
|
115
|
+
);
|
|
116
|
+
try {
|
|
117
|
+
await page.screenshot({ path: pngPath });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!isZeroWidthScreenshotError(error)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
await forceSnapshotViewport(
|
|
123
|
+
page,
|
|
124
|
+
restoreViewport,
|
|
125
|
+
logger,
|
|
126
|
+
session,
|
|
127
|
+
pageId,
|
|
128
|
+
"retry-after-zero-width-screenshot-error"
|
|
129
|
+
);
|
|
130
|
+
await page.screenshot({ path: pngPath });
|
|
131
|
+
}
|
|
27
132
|
const htmlContent = await page.content();
|
|
28
133
|
const fs = await import("node:fs/promises");
|
|
29
134
|
await fs.writeFile(htmlPath, htmlContent);
|
|
@@ -49,7 +154,13 @@ async function captureScreenshot(session, logger, pageId) {
|
|
|
49
154
|
session,
|
|
50
155
|
pageAlive,
|
|
51
156
|
browserConnected,
|
|
52
|
-
pageUrl:
|
|
157
|
+
pageUrl: (() => {
|
|
158
|
+
try {
|
|
159
|
+
return page.url();
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
})()
|
|
53
164
|
});
|
|
54
165
|
throw err;
|
|
55
166
|
} finally {
|
|
@@ -10,9 +10,14 @@ const AiConfigSchema = z.object({
|
|
|
10
10
|
commandPrefix: z.array(z.string()).min(1),
|
|
11
11
|
updatedAt: z.string()
|
|
12
12
|
}).strict();
|
|
13
|
+
const ViewportConfigSchema = z.object({
|
|
14
|
+
width: z.number().int().min(1),
|
|
15
|
+
height: z.number().int().min(1)
|
|
16
|
+
});
|
|
13
17
|
const LibrettoConfigSchema = z.object({
|
|
14
18
|
version: z.literal(CURRENT_CONFIG_VERSION),
|
|
15
|
-
ai: AiConfigSchema.optional()
|
|
19
|
+
ai: AiConfigSchema.optional(),
|
|
20
|
+
viewport: ViewportConfigSchema.optional()
|
|
16
21
|
}).passthrough();
|
|
17
22
|
const AI_CONFIG_PRESETS = {
|
|
18
23
|
codex: ["codex", "exec", "--skip-git-repo-check", "--sandbox", "read-only"],
|
|
@@ -73,9 +78,10 @@ function writeAiConfig(preset, commandPrefix, configPath = LIBRETTO_CONFIG_PATH)
|
|
|
73
78
|
function clearAiConfig(configPath = LIBRETTO_CONFIG_PATH) {
|
|
74
79
|
const librettoConfig = readLibrettoConfig(configPath);
|
|
75
80
|
if (!librettoConfig.ai) return false;
|
|
81
|
+
const { ai: _ai, ...rest } = librettoConfig;
|
|
76
82
|
writeLibrettoConfig(
|
|
77
83
|
{
|
|
78
|
-
|
|
84
|
+
...rest
|
|
79
85
|
},
|
|
80
86
|
configPath
|
|
81
87
|
);
|
|
@@ -139,6 +145,7 @@ export {
|
|
|
139
145
|
AiPresetSchema,
|
|
140
146
|
CURRENT_CONFIG_VERSION,
|
|
141
147
|
LibrettoConfigSchema,
|
|
148
|
+
ViewportConfigSchema,
|
|
142
149
|
clearAiConfig,
|
|
143
150
|
formatCommandPrefix,
|
|
144
151
|
readAiConfig,
|
package/dist/cli/core/browser.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getSessionNetworkLogPath,
|
|
10
10
|
PROFILES_DIR
|
|
11
11
|
} from "./context.js";
|
|
12
|
+
import { readLibrettoConfig } from "./ai-config.js";
|
|
12
13
|
import {
|
|
13
14
|
assertSessionAvailableForStart,
|
|
14
15
|
clearSessionState,
|
|
@@ -216,9 +217,24 @@ async function runPages(session, logger) {
|
|
|
216
217
|
console.log(` id=${pageSummary.id} url=${pageSummary.url}${activeSuffix}`);
|
|
217
218
|
});
|
|
218
219
|
}
|
|
219
|
-
|
|
220
|
+
const DEFAULT_VIEWPORT = { width: 1366, height: 768 };
|
|
221
|
+
function resolveViewport(cliViewport, logger) {
|
|
222
|
+
if (cliViewport) {
|
|
223
|
+
logger.info("viewport-source", { source: "cli", viewport: cliViewport });
|
|
224
|
+
return cliViewport;
|
|
225
|
+
}
|
|
226
|
+
const config = readLibrettoConfig();
|
|
227
|
+
if (config.viewport) {
|
|
228
|
+
logger.info("viewport-source", { source: "config", viewport: config.viewport });
|
|
229
|
+
return config.viewport;
|
|
230
|
+
}
|
|
231
|
+
logger.info("viewport-source", { source: "default", viewport: DEFAULT_VIEWPORT });
|
|
232
|
+
return DEFAULT_VIEWPORT;
|
|
233
|
+
}
|
|
234
|
+
async function runOpen(rawUrl, headed, session, logger, options) {
|
|
220
235
|
const url = normalizeUrl(rawUrl);
|
|
221
|
-
|
|
236
|
+
const viewport = resolveViewport(options?.viewport, logger);
|
|
237
|
+
logger.info("open-start", { url, headed, session, viewport });
|
|
222
238
|
assertSessionAvailableForStart(session, logger);
|
|
223
239
|
const port = await pickFreePort();
|
|
224
240
|
const runLogPath = logFileForSession(session);
|
|
@@ -296,7 +312,7 @@ browser.on('disconnected', () => {
|
|
|
296
312
|
|
|
297
313
|
const context = await browser.newContext({
|
|
298
314
|
${storageStateCode}
|
|
299
|
-
viewport: { width:
|
|
315
|
+
viewport: { width: ${viewport.width}, height: ${viewport.height} },
|
|
300
316
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
|
301
317
|
});
|
|
302
318
|
|
|
@@ -398,7 +414,8 @@ await new Promise(() => {});
|
|
|
398
414
|
pid: child.pid,
|
|
399
415
|
session,
|
|
400
416
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
401
|
-
status: "active"
|
|
417
|
+
status: "active",
|
|
418
|
+
viewport
|
|
402
419
|
}, logger);
|
|
403
420
|
logger.info("open-success", {
|
|
404
421
|
url,
|
|
@@ -66,8 +66,8 @@ class UserCodingAgent {
|
|
|
66
66
|
Screenshot file path: ${pngPath}
|
|
67
67
|
Use the screenshot alongside the HTML snapshot context above.`;
|
|
68
68
|
}
|
|
69
|
-
async runAnalyzer(args, stdinText) {
|
|
70
|
-
const result = await runExternalCommand(this.command, args, stdinText);
|
|
69
|
+
async runAnalyzer(args, logger, stdinText) {
|
|
70
|
+
const result = await runExternalCommand(this.command, args, logger, stdinText);
|
|
71
71
|
if (result.exitCode !== 0) {
|
|
72
72
|
throw new Error(
|
|
73
73
|
`Analyzer command failed (${formatCommandPrefix([this.command, ...args])}).
|
|
@@ -76,13 +76,13 @@ ${stripAnsi(result.stderr).trim() || stripAnsi(result.stdout).trim() || "No erro
|
|
|
76
76
|
}
|
|
77
77
|
return result;
|
|
78
78
|
}
|
|
79
|
-
async runAndParse(args, stdinText) {
|
|
80
|
-
const result = await this.runAnalyzer(args, stdinText);
|
|
79
|
+
async runAndParse(args, logger, stdinText) {
|
|
80
|
+
const result = await this.runAnalyzer(args, logger, stdinText);
|
|
81
81
|
return parseInterpretResultFromText(result.stdout);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
class CodexUserCodingAgent extends UserCodingAgent {
|
|
85
|
-
async analyzeSnapshot(prompt, pngPath) {
|
|
85
|
+
async analyzeSnapshot(prompt, pngPath, logger) {
|
|
86
86
|
const tempDir = mkdtempSync(join(tmpdir(), "libretto-cli-analyzer-"));
|
|
87
87
|
const outputPath = join(
|
|
88
88
|
tempDir,
|
|
@@ -96,9 +96,21 @@ class CodexUserCodingAgent extends UserCodingAgent {
|
|
|
96
96
|
pngPath,
|
|
97
97
|
"-"
|
|
98
98
|
];
|
|
99
|
-
|
|
99
|
+
logger.info("interpret-analyzer-codex-start", {
|
|
100
|
+
outputPath,
|
|
101
|
+
pngPath,
|
|
102
|
+
promptChars: prompt.length,
|
|
103
|
+
args
|
|
104
|
+
});
|
|
105
|
+
const result = await this.runAnalyzer(args, logger, prompt);
|
|
100
106
|
let outputText = result.stdout;
|
|
101
107
|
try {
|
|
108
|
+
logger.info("interpret-analyzer-codex-finish", {
|
|
109
|
+
outputPath,
|
|
110
|
+
outputFileExists: existsSync(outputPath),
|
|
111
|
+
stdoutChars: result.stdout.length,
|
|
112
|
+
stderrChars: result.stderr.length
|
|
113
|
+
});
|
|
102
114
|
if (existsSync(outputPath)) {
|
|
103
115
|
outputText = readFileSync(outputPath, "utf-8");
|
|
104
116
|
}
|
|
@@ -109,31 +121,59 @@ class CodexUserCodingAgent extends UserCodingAgent {
|
|
|
109
121
|
}
|
|
110
122
|
}
|
|
111
123
|
class ClaudeUserCodingAgent extends UserCodingAgent {
|
|
112
|
-
async analyzeSnapshot(prompt, pngPath) {
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
async analyzeSnapshot(prompt, pngPath, logger) {
|
|
125
|
+
return await this.runAndParse(
|
|
126
|
+
[...this.baseArgs],
|
|
127
|
+
logger,
|
|
128
|
+
`${prompt}${this.screenshotHint(pngPath)}`
|
|
129
|
+
);
|
|
115
130
|
}
|
|
116
131
|
}
|
|
117
132
|
class GeminiUserCodingAgent extends UserCodingAgent {
|
|
118
|
-
async analyzeSnapshot(prompt, pngPath) {
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
async analyzeSnapshot(prompt, pngPath, logger) {
|
|
134
|
+
return await this.runAndParse(
|
|
135
|
+
[...this.baseArgs],
|
|
136
|
+
logger,
|
|
137
|
+
`${prompt}${this.screenshotHint(pngPath)}`
|
|
138
|
+
);
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
|
-
async function runExternalCommand(command, args, stdinText) {
|
|
141
|
+
async function runExternalCommand(command, args, logger, stdinText) {
|
|
124
142
|
return await new Promise((resolve2, reject) => {
|
|
143
|
+
const startedAt = Date.now();
|
|
144
|
+
logger.info("interpret-analyzer-spawn-start", {
|
|
145
|
+
command,
|
|
146
|
+
args,
|
|
147
|
+
stdinChars: stdinText?.length ?? 0
|
|
148
|
+
});
|
|
125
149
|
const child = spawn(command, args, {
|
|
126
150
|
stdio: ["pipe", "pipe", "pipe"]
|
|
127
151
|
});
|
|
128
152
|
let stdout = "";
|
|
129
153
|
let stderr = "";
|
|
154
|
+
let stdinError = null;
|
|
130
155
|
child.stdout.on("data", (chunk) => {
|
|
131
156
|
stdout += chunk.toString();
|
|
132
157
|
});
|
|
133
158
|
child.stderr.on("data", (chunk) => {
|
|
134
159
|
stderr += chunk.toString();
|
|
135
160
|
});
|
|
161
|
+
child.stdin.on("error", (err) => {
|
|
162
|
+
stdinError = err;
|
|
163
|
+
logger.warn("interpret-analyzer-stdin-pipe-error", {
|
|
164
|
+
command,
|
|
165
|
+
args,
|
|
166
|
+
code: stdinError.code ?? null,
|
|
167
|
+
message: stdinError.message,
|
|
168
|
+
hint: stdinError.code === "EPIPE" ? "Child process exited before consuming all stdin data" : "Unexpected stdin write error"
|
|
169
|
+
});
|
|
170
|
+
});
|
|
136
171
|
child.on("error", (err) => {
|
|
172
|
+
logger.error("interpret-analyzer-spawn-error", {
|
|
173
|
+
command,
|
|
174
|
+
args,
|
|
175
|
+
error: err
|
|
176
|
+
});
|
|
137
177
|
const error = err;
|
|
138
178
|
if (error.code === "ENOENT") {
|
|
139
179
|
reject(
|
|
@@ -146,16 +186,41 @@ async function runExternalCommand(command, args, stdinText) {
|
|
|
146
186
|
reject(err);
|
|
147
187
|
});
|
|
148
188
|
child.on("close", (code) => {
|
|
189
|
+
const stdinNote = formatStdinError(stderr, stdinError);
|
|
190
|
+
const combinedStderr = `${stderr}${stdinNote}`;
|
|
191
|
+
logger.info("interpret-analyzer-spawn-close", {
|
|
192
|
+
command,
|
|
193
|
+
args,
|
|
194
|
+
exitCode: code ?? 1,
|
|
195
|
+
durationMs: Date.now() - startedAt,
|
|
196
|
+
stdoutChars: stdout.length,
|
|
197
|
+
stderrChars: combinedStderr.length,
|
|
198
|
+
stdinErrorCode: stdinError?.code ?? null,
|
|
199
|
+
stdoutPreview: summarizeForLog(stdout),
|
|
200
|
+
stderrPreview: summarizeForLog(combinedStderr)
|
|
201
|
+
});
|
|
149
202
|
resolve2({
|
|
150
203
|
exitCode: code ?? 1,
|
|
151
204
|
stdout,
|
|
152
|
-
stderr
|
|
205
|
+
stderr: combinedStderr
|
|
153
206
|
});
|
|
154
207
|
});
|
|
155
|
-
|
|
156
|
-
|
|
208
|
+
try {
|
|
209
|
+
if (stdinText !== void 0) {
|
|
210
|
+
child.stdin.end(stdinText);
|
|
211
|
+
} else {
|
|
212
|
+
child.stdin.end();
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
stdinError = err;
|
|
216
|
+
logger.warn("interpret-analyzer-stdin-write-error", {
|
|
217
|
+
command,
|
|
218
|
+
args,
|
|
219
|
+
code: stdinError.code ?? null,
|
|
220
|
+
message: stdinError.message,
|
|
221
|
+
hint: stdinError.code === "EPIPE" ? "Child process exited before consuming all stdin data" : "Unexpected stdin write error"
|
|
222
|
+
});
|
|
157
223
|
}
|
|
158
|
-
child.stdin.end();
|
|
159
224
|
});
|
|
160
225
|
}
|
|
161
226
|
function stripAnsi(value) {
|
|
@@ -164,6 +229,19 @@ function stripAnsi(value) {
|
|
|
164
229
|
""
|
|
165
230
|
);
|
|
166
231
|
}
|
|
232
|
+
function summarizeForLog(value, maxChars = 800) {
|
|
233
|
+
const cleaned = stripAnsi(value).trim();
|
|
234
|
+
if (!cleaned) return "";
|
|
235
|
+
if (cleaned.length <= maxChars) return cleaned;
|
|
236
|
+
return `${cleaned.slice(0, maxChars)}\u2026 [truncated ${cleaned.length - maxChars} chars]`;
|
|
237
|
+
}
|
|
238
|
+
function formatStdinError(stderr, error) {
|
|
239
|
+
if (!error) return "";
|
|
240
|
+
const detail = error.code === "EPIPE" ? "Analyzer closed stdin before Libretto finished sending the snapshot prompt." : `Analyzer stdin error: ${error.message}`;
|
|
241
|
+
if (stderr.includes(detail)) return "";
|
|
242
|
+
return `${stderr.endsWith("\n") || stderr.length === 0 ? "" : "\n"}${detail}
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
167
245
|
function extractJsonObjectCandidates(text) {
|
|
168
246
|
const candidates = [];
|
|
169
247
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -430,7 +508,7 @@ ${trimmedHtml}`;
|
|
|
430
508
|
preset: configuredAnalyzer.preset,
|
|
431
509
|
commandPrefix: configuredAnalyzer.commandPrefix
|
|
432
510
|
});
|
|
433
|
-
parsed = await configuredAgent.analyzeSnapshot(prompt, pngPath);
|
|
511
|
+
parsed = await configuredAgent.analyzeSnapshot(prompt, pngPath, logger);
|
|
434
512
|
} else {
|
|
435
513
|
const llmClientFactory = getLLMClientFactory();
|
|
436
514
|
if (!llmClientFactory) {
|
package/dist/cli/index.js
CHANGED
|
@@ -42,7 +42,7 @@ interface LLMClient {
|
|
|
42
42
|
prompt: string;
|
|
43
43
|
schema: T;
|
|
44
44
|
temperature?: number;
|
|
45
|
-
}): Promise<z.
|
|
45
|
+
}): Promise<z.output<T>>;
|
|
46
46
|
/**
|
|
47
47
|
* Generate a structured object from a conversation-style message array.
|
|
48
48
|
*
|
|
@@ -60,7 +60,7 @@ interface LLMClient {
|
|
|
60
60
|
messages: Message[];
|
|
61
61
|
schema: T;
|
|
62
62
|
temperature?: number;
|
|
63
|
-
}): Promise<z.
|
|
63
|
+
}): Promise<z.output<T>>;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export type { LLMClient, Message, MessageContentPart };
|
|
@@ -42,7 +42,7 @@ interface LLMClient {
|
|
|
42
42
|
prompt: string;
|
|
43
43
|
schema: T;
|
|
44
44
|
temperature?: number;
|
|
45
|
-
}): Promise<z.
|
|
45
|
+
}): Promise<z.output<T>>;
|
|
46
46
|
/**
|
|
47
47
|
* Generate a structured object from a conversation-style message array.
|
|
48
48
|
*
|
|
@@ -60,7 +60,7 @@ interface LLMClient {
|
|
|
60
60
|
messages: Message[];
|
|
61
61
|
schema: T;
|
|
62
62
|
temperature?: number;
|
|
63
|
-
}): Promise<z.
|
|
63
|
+
}): Promise<z.output<T>>;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export type { LLMClient, Message, MessageContentPart };
|
|
@@ -21,6 +21,7 @@ __export(session_state_exports, {
|
|
|
21
21
|
SESSION_STATE_VERSION: () => SESSION_STATE_VERSION,
|
|
22
22
|
SessionStateFileSchema: () => SessionStateFileSchema,
|
|
23
23
|
SessionStatusSchema: () => SessionStatusSchema,
|
|
24
|
+
SessionViewportSchema: () => SessionViewportSchema,
|
|
24
25
|
parseSessionStateContent: () => parseSessionStateContent,
|
|
25
26
|
parseSessionStateData: () => parseSessionStateData,
|
|
26
27
|
serializeSessionState: () => serializeSessionState
|
|
@@ -35,13 +36,18 @@ const SessionStatusSchema = import_zod.z.enum([
|
|
|
35
36
|
"failed",
|
|
36
37
|
"exited"
|
|
37
38
|
]);
|
|
39
|
+
const SessionViewportSchema = import_zod.z.object({
|
|
40
|
+
width: import_zod.z.number().int().min(1),
|
|
41
|
+
height: import_zod.z.number().int().min(1)
|
|
42
|
+
});
|
|
38
43
|
const SessionStateFileSchema = import_zod.z.object({
|
|
39
44
|
version: import_zod.z.literal(SESSION_STATE_VERSION),
|
|
40
45
|
port: import_zod.z.number().int().min(0).max(65535),
|
|
41
46
|
pid: import_zod.z.number().int(),
|
|
42
47
|
session: import_zod.z.string().min(1),
|
|
43
48
|
startedAt: import_zod.z.string().datetime({ offset: true }),
|
|
44
|
-
status: SessionStatusSchema.optional()
|
|
49
|
+
status: SessionStatusSchema.optional(),
|
|
50
|
+
viewport: SessionViewportSchema.optional()
|
|
45
51
|
});
|
|
46
52
|
function formatIssues(error) {
|
|
47
53
|
return error.issues.map((issue) => {
|
|
@@ -79,6 +85,7 @@ function serializeSessionState(state) {
|
|
|
79
85
|
SESSION_STATE_VERSION,
|
|
80
86
|
SessionStateFileSchema,
|
|
81
87
|
SessionStatusSchema,
|
|
88
|
+
SessionViewportSchema,
|
|
82
89
|
parseSessionStateContent,
|
|
83
90
|
parseSessionStateData,
|
|
84
91
|
serializeSessionState
|
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
declare const SESSION_STATE_VERSION = 1;
|
|
4
|
-
declare const SessionStatusSchema: z.ZodEnum<
|
|
4
|
+
declare const SessionStatusSchema: z.ZodEnum<{
|
|
5
|
+
active: "active";
|
|
6
|
+
paused: "paused";
|
|
7
|
+
completed: "completed";
|
|
8
|
+
failed: "failed";
|
|
9
|
+
exited: "exited";
|
|
10
|
+
}>;
|
|
11
|
+
declare const SessionViewportSchema: z.ZodObject<{
|
|
12
|
+
width: z.ZodNumber;
|
|
13
|
+
height: z.ZodNumber;
|
|
14
|
+
}, z.core.$strip>;
|
|
5
15
|
declare const SessionStateFileSchema: z.ZodObject<{
|
|
6
16
|
version: z.ZodLiteral<1>;
|
|
7
17
|
port: z.ZodNumber;
|
|
8
18
|
pid: z.ZodNumber;
|
|
9
19
|
session: z.ZodString;
|
|
10
20
|
startedAt: z.ZodString;
|
|
11
|
-
status: z.ZodOptional<z.ZodEnum<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
session: string;
|
|
24
|
-
startedAt: string;
|
|
25
|
-
status?: "active" | "paused" | "completed" | "failed" | "exited" | undefined;
|
|
26
|
-
}>;
|
|
21
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
22
|
+
active: "active";
|
|
23
|
+
paused: "paused";
|
|
24
|
+
completed: "completed";
|
|
25
|
+
failed: "failed";
|
|
26
|
+
exited: "exited";
|
|
27
|
+
}>>;
|
|
28
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
29
|
+
width: z.ZodNumber;
|
|
30
|
+
height: z.ZodNumber;
|
|
31
|
+
}, z.core.$strip>>;
|
|
32
|
+
}, z.core.$strip>;
|
|
27
33
|
type SessionStatus = z.infer<typeof SessionStatusSchema>;
|
|
28
34
|
type SessionStateFile = z.infer<typeof SessionStateFileSchema>;
|
|
29
35
|
type SessionState = Omit<SessionStateFile, "version">;
|
|
@@ -31,4 +37,4 @@ declare function parseSessionStateData(rawState: unknown, source: string): Sessi
|
|
|
31
37
|
declare function parseSessionStateContent(content: string, source: string): SessionState;
|
|
32
38
|
declare function serializeSessionState(state: SessionState): SessionStateFile;
|
|
33
39
|
|
|
34
|
-
export { SESSION_STATE_VERSION, type SessionState, type SessionStateFile, SessionStateFileSchema, type SessionStatus, SessionStatusSchema, parseSessionStateContent, parseSessionStateData, serializeSessionState };
|
|
40
|
+
export { SESSION_STATE_VERSION, type SessionState, type SessionStateFile, SessionStateFileSchema, type SessionStatus, SessionStatusSchema, SessionViewportSchema, parseSessionStateContent, parseSessionStateData, serializeSessionState };
|
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
declare const SESSION_STATE_VERSION = 1;
|
|
4
|
-
declare const SessionStatusSchema: z.ZodEnum<
|
|
4
|
+
declare const SessionStatusSchema: z.ZodEnum<{
|
|
5
|
+
active: "active";
|
|
6
|
+
paused: "paused";
|
|
7
|
+
completed: "completed";
|
|
8
|
+
failed: "failed";
|
|
9
|
+
exited: "exited";
|
|
10
|
+
}>;
|
|
11
|
+
declare const SessionViewportSchema: z.ZodObject<{
|
|
12
|
+
width: z.ZodNumber;
|
|
13
|
+
height: z.ZodNumber;
|
|
14
|
+
}, z.core.$strip>;
|
|
5
15
|
declare const SessionStateFileSchema: z.ZodObject<{
|
|
6
16
|
version: z.ZodLiteral<1>;
|
|
7
17
|
port: z.ZodNumber;
|
|
8
18
|
pid: z.ZodNumber;
|
|
9
19
|
session: z.ZodString;
|
|
10
20
|
startedAt: z.ZodString;
|
|
11
|
-
status: z.ZodOptional<z.ZodEnum<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
session: string;
|
|
24
|
-
startedAt: string;
|
|
25
|
-
status?: "active" | "paused" | "completed" | "failed" | "exited" | undefined;
|
|
26
|
-
}>;
|
|
21
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
22
|
+
active: "active";
|
|
23
|
+
paused: "paused";
|
|
24
|
+
completed: "completed";
|
|
25
|
+
failed: "failed";
|
|
26
|
+
exited: "exited";
|
|
27
|
+
}>>;
|
|
28
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
29
|
+
width: z.ZodNumber;
|
|
30
|
+
height: z.ZodNumber;
|
|
31
|
+
}, z.core.$strip>>;
|
|
32
|
+
}, z.core.$strip>;
|
|
27
33
|
type SessionStatus = z.infer<typeof SessionStatusSchema>;
|
|
28
34
|
type SessionStateFile = z.infer<typeof SessionStateFileSchema>;
|
|
29
35
|
type SessionState = Omit<SessionStateFile, "version">;
|
|
@@ -31,4 +37,4 @@ declare function parseSessionStateData(rawState: unknown, source: string): Sessi
|
|
|
31
37
|
declare function parseSessionStateContent(content: string, source: string): SessionState;
|
|
32
38
|
declare function serializeSessionState(state: SessionState): SessionStateFile;
|
|
33
39
|
|
|
34
|
-
export { SESSION_STATE_VERSION, type SessionState, type SessionStateFile, SessionStateFileSchema, type SessionStatus, SessionStatusSchema, parseSessionStateContent, parseSessionStateData, serializeSessionState };
|
|
40
|
+
export { SESSION_STATE_VERSION, type SessionState, type SessionStateFile, SessionStateFileSchema, type SessionStatus, SessionStatusSchema, SessionViewportSchema, parseSessionStateContent, parseSessionStateData, serializeSessionState };
|
|
@@ -7,13 +7,18 @@ const SessionStatusSchema = z.enum([
|
|
|
7
7
|
"failed",
|
|
8
8
|
"exited"
|
|
9
9
|
]);
|
|
10
|
+
const SessionViewportSchema = z.object({
|
|
11
|
+
width: z.number().int().min(1),
|
|
12
|
+
height: z.number().int().min(1)
|
|
13
|
+
});
|
|
10
14
|
const SessionStateFileSchema = z.object({
|
|
11
15
|
version: z.literal(SESSION_STATE_VERSION),
|
|
12
16
|
port: z.number().int().min(0).max(65535),
|
|
13
17
|
pid: z.number().int(),
|
|
14
18
|
session: z.string().min(1),
|
|
15
19
|
startedAt: z.string().datetime({ offset: true }),
|
|
16
|
-
status: SessionStatusSchema.optional()
|
|
20
|
+
status: SessionStatusSchema.optional(),
|
|
21
|
+
viewport: SessionViewportSchema.optional()
|
|
17
22
|
});
|
|
18
23
|
function formatIssues(error) {
|
|
19
24
|
return error.issues.map((issue) => {
|
|
@@ -50,6 +55,7 @@ export {
|
|
|
50
55
|
SESSION_STATE_VERSION,
|
|
51
56
|
SessionStateFileSchema,
|
|
52
57
|
SessionStatusSchema,
|
|
58
|
+
SessionViewportSchema,
|
|
53
59
|
parseSessionStateContent,
|
|
54
60
|
parseSessionStateData,
|
|
55
61
|
serializeSessionState
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libretto",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "AI-powered browser automation library and CLI built on Playwright",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"url": "https://github.com/saffron-health/libretto"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
|
+
"packageManager": "pnpm@9.15.4",
|
|
11
12
|
"publishConfig": {
|
|
12
13
|
"access": "public"
|
|
13
14
|
},
|
|
@@ -26,11 +27,23 @@
|
|
|
26
27
|
"require": "./dist/index.cjs"
|
|
27
28
|
}
|
|
28
29
|
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"postinstall": "playwright install chromium",
|
|
32
|
+
"build": "pnpm run build:runtime && pnpm run build:cli",
|
|
33
|
+
"build:runtime": "tsup --config tsup.config.ts",
|
|
34
|
+
"build:cli": "tsup --config tsup.cli.config.ts",
|
|
35
|
+
"type-check": "tsc --noEmit",
|
|
36
|
+
"test": "pnpm run build && vitest run",
|
|
37
|
+
"eval": "pnpm run build && vitest run --config vitest.evals.config.ts",
|
|
38
|
+
"benchmark": "pnpm run build && tsx benchmarks/run.ts",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"cli": "node dist/index.js",
|
|
41
|
+
"prepack": "pnpm run build"
|
|
42
|
+
},
|
|
29
43
|
"peerDependencies": {
|
|
30
|
-
"@ai-sdk/anthropic": "^3.0.
|
|
31
|
-
"@ai-sdk/google-vertex": "^4.0.
|
|
32
|
-
"@ai-sdk/openai": "^3.0.
|
|
33
|
-
"zod": ">=3.0.0"
|
|
44
|
+
"@ai-sdk/anthropic": "^3.0.58",
|
|
45
|
+
"@ai-sdk/google-vertex": "^4.0.80",
|
|
46
|
+
"@ai-sdk/openai": "^3.0.41"
|
|
34
47
|
},
|
|
35
48
|
"peerDependenciesMeta": {
|
|
36
49
|
"@ai-sdk/anthropic": {
|
|
@@ -44,34 +57,22 @@
|
|
|
44
57
|
}
|
|
45
58
|
},
|
|
46
59
|
"devDependencies": {
|
|
47
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
48
|
-
"@ai-sdk/anthropic": "^3.0.
|
|
49
|
-
"@ai-sdk/google-vertex": "^4.0.
|
|
50
|
-
"@ai-sdk/openai": "^3.0.
|
|
51
|
-
"@types/node": "^25.
|
|
52
|
-
"@types/yargs": "^17.0.
|
|
53
|
-
"openai": "^6.
|
|
54
|
-
"tsup": "^8.
|
|
55
|
-
"typescript": "^5.
|
|
56
|
-
"vitest": "^4.0
|
|
57
|
-
"zod": "^3.25.0"
|
|
60
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.75",
|
|
61
|
+
"@ai-sdk/anthropic": "^3.0.58",
|
|
62
|
+
"@ai-sdk/google-vertex": "^4.0.80",
|
|
63
|
+
"@ai-sdk/openai": "^3.0.41",
|
|
64
|
+
"@types/node": "^25.5.0",
|
|
65
|
+
"@types/yargs": "^17.0.35",
|
|
66
|
+
"openai": "^6.29.0",
|
|
67
|
+
"tsup": "^8.5.1",
|
|
68
|
+
"typescript": "^5.9.3",
|
|
69
|
+
"vitest": "^4.1.0"
|
|
58
70
|
},
|
|
59
71
|
"dependencies": {
|
|
60
|
-
"ai": "^6.0.
|
|
61
|
-
"playwright": "^1.
|
|
62
|
-
"tsx": "^4.
|
|
63
|
-
"yargs": "^
|
|
64
|
-
|
|
65
|
-
"scripts": {
|
|
66
|
-
"postinstall": "npx playwright install chromium",
|
|
67
|
-
"build": "pnpm run build:runtime && pnpm run build:cli",
|
|
68
|
-
"build:runtime": "tsup --config tsup.config.ts",
|
|
69
|
-
"build:cli": "tsup --config tsup.cli.config.ts",
|
|
70
|
-
"type-check": "tsc --noEmit",
|
|
71
|
-
"test": "pnpm run build && vitest run",
|
|
72
|
-
"eval": "pnpm run build && vitest run --config vitest.evals.config.ts",
|
|
73
|
-
"benchmark": "pnpm run build && tsx benchmarks/run.ts",
|
|
74
|
-
"test:watch": "vitest",
|
|
75
|
-
"cli": "node dist/index.js"
|
|
72
|
+
"ai": "^6.0.116",
|
|
73
|
+
"playwright": "^1.58.2",
|
|
74
|
+
"tsx": "^4.21.0",
|
|
75
|
+
"yargs": "^18.0.0",
|
|
76
|
+
"zod": "^4.3.6"
|
|
76
77
|
}
|
|
77
|
-
}
|
|
78
|
+
}
|