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.
- package/dist/cli/cli.js +20 -19
- package/dist/cli/commands/ai.js +1 -1
- package/dist/cli/commands/browser.js +3 -3
- package/dist/cli/commands/execution.js +3 -3
- package/dist/cli/commands/logs.js +1 -1
- package/dist/cli/core/browser.js +11 -6
- package/dist/cli/core/context.js +4 -18
- package/dist/cli/core/session.js +2 -2
- package/dist/cli/core/snapshot-analyzer.js +2 -2
- package/dist/cli/router.js +1 -1
- package/dist/cli/workers/run-integration-runtime.js +2 -2
- package/dist/shared/paths/paths.js +2 -1
- package/dist/shared/paths/repo-root.d.ts +3 -0
- package/dist/shared/paths/repo-root.js +24 -0
- package/package.json +6 -7
- package/scripts/postinstall.mjs +12 -3
- package/skills/libretto/SKILL.md +93 -404
- package/skills/libretto/references/auth-profiles.md +30 -0
- package/skills/libretto/references/pages-and-page-targeting.md +29 -0
- package/skills/libretto/references/reverse-engineering-network-requests.md +39 -0
- package/skills/libretto/references/user-action-log.md +31 -0
- package/src/cli/cli.ts +173 -0
- package/src/cli/commands/ai.ts +35 -0
- package/src/cli/commands/browser.ts +165 -0
- package/src/cli/commands/execution.ts +691 -0
- package/src/cli/commands/init.ts +327 -0
- package/src/cli/commands/logs.ts +128 -0
- package/src/cli/commands/shared.ts +70 -0
- package/src/cli/commands/snapshot.ts +327 -0
- package/src/cli/core/ai-config.ts +255 -0
- package/src/cli/core/api-snapshot-analyzer.ts +97 -0
- package/src/cli/core/browser.ts +839 -0
- package/src/cli/core/context.ts +122 -0
- package/src/cli/core/pause-signals.ts +35 -0
- package/src/cli/core/session-telemetry.ts +553 -0
- package/src/cli/core/session.ts +209 -0
- package/src/cli/core/snapshot-analyzer.ts +875 -0
- package/src/cli/core/snapshot-api-config.ts +236 -0
- package/src/cli/core/telemetry.ts +446 -0
- package/src/cli/framework/simple-cli.ts +1273 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/router.ts +28 -0
- package/src/cli/workers/run-integration-runtime.ts +311 -0
- package/src/cli/workers/run-integration-worker-protocol.ts +14 -0
- package/src/cli/workers/run-integration-worker.ts +75 -0
- package/src/index.ts +120 -0
- package/src/runtime/download/download.ts +100 -0
- package/src/runtime/download/index.ts +7 -0
- package/src/runtime/extract/extract.ts +92 -0
- package/src/runtime/extract/index.ts +1 -0
- package/src/runtime/network/index.ts +5 -0
- package/src/runtime/network/network.ts +113 -0
- package/src/runtime/recovery/agent.ts +256 -0
- package/src/runtime/recovery/errors.ts +152 -0
- package/src/runtime/recovery/index.ts +7 -0
- package/src/runtime/recovery/recovery.ts +50 -0
- package/{dist/shared/condense-dom/condense-dom.cjs → src/shared/condense-dom/condense-dom.ts} +243 -115
- package/src/shared/config/config.ts +22 -0
- package/src/shared/config/index.ts +5 -0
- package/src/shared/debug/index.ts +1 -0
- package/src/shared/debug/pause.ts +85 -0
- package/src/shared/instrumentation/errors.ts +82 -0
- package/src/shared/instrumentation/index.ts +9 -0
- package/src/shared/instrumentation/instrument.ts +276 -0
- package/src/shared/llm/ai-sdk-adapter.ts +78 -0
- package/src/shared/llm/client.ts +217 -0
- package/src/shared/llm/index.ts +3 -0
- package/src/shared/llm/types.ts +63 -0
- package/src/shared/logger/index.ts +6 -0
- package/src/shared/logger/logger.ts +352 -0
- package/src/shared/logger/sinks.ts +144 -0
- package/src/shared/paths/paths.ts +109 -0
- package/src/shared/paths/repo-root.ts +27 -0
- package/src/shared/run/api.ts +2 -0
- package/src/shared/run/browser.ts +98 -0
- package/src/shared/state/index.ts +11 -0
- package/src/shared/state/session-state.ts +74 -0
- package/src/shared/visualization/ghost-cursor.ts +200 -0
- package/src/shared/visualization/highlight.ts +146 -0
- package/src/shared/visualization/index.ts +18 -0
- package/src/shared/workflow/workflow.ts +42 -0
- package/dist/index.cjs +0 -144
- package/dist/index.d.cts +0 -21
- package/dist/runtime/download/download.cjs +0 -70
- package/dist/runtime/download/download.d.cts +0 -35
- package/dist/runtime/download/index.cjs +0 -30
- package/dist/runtime/download/index.d.cts +0 -3
- package/dist/runtime/extract/extract.cjs +0 -88
- package/dist/runtime/extract/extract.d.cts +0 -23
- package/dist/runtime/extract/index.cjs +0 -28
- package/dist/runtime/extract/index.d.cts +0 -5
- package/dist/runtime/network/index.cjs +0 -28
- package/dist/runtime/network/index.d.cts +0 -4
- package/dist/runtime/network/network.cjs +0 -91
- package/dist/runtime/network/network.d.cts +0 -28
- package/dist/runtime/recovery/agent.cjs +0 -223
- package/dist/runtime/recovery/agent.d.cts +0 -13
- package/dist/runtime/recovery/errors.cjs +0 -124
- package/dist/runtime/recovery/errors.d.cts +0 -31
- package/dist/runtime/recovery/index.cjs +0 -34
- package/dist/runtime/recovery/index.d.cts +0 -7
- package/dist/runtime/recovery/recovery.cjs +0 -55
- package/dist/runtime/recovery/recovery.d.cts +0 -12
- package/dist/shared/condense-dom/condense-dom.d.cts +0 -34
- package/dist/shared/config/config.cjs +0 -44
- package/dist/shared/config/config.d.cts +0 -10
- package/dist/shared/config/index.cjs +0 -32
- package/dist/shared/config/index.d.cts +0 -1
- package/dist/shared/debug/index.cjs +0 -28
- package/dist/shared/debug/index.d.cts +0 -1
- package/dist/shared/debug/pause.cjs +0 -86
- package/dist/shared/debug/pause.d.cts +0 -12
- package/dist/shared/instrumentation/errors.cjs +0 -81
- package/dist/shared/instrumentation/errors.d.cts +0 -12
- package/dist/shared/instrumentation/index.cjs +0 -35
- package/dist/shared/instrumentation/index.d.cts +0 -6
- package/dist/shared/instrumentation/instrument.cjs +0 -206
- package/dist/shared/instrumentation/instrument.d.cts +0 -32
- package/dist/shared/llm/ai-sdk-adapter.cjs +0 -71
- package/dist/shared/llm/ai-sdk-adapter.d.cts +0 -22
- package/dist/shared/llm/client.cjs +0 -218
- package/dist/shared/llm/client.d.cts +0 -13
- package/dist/shared/llm/index.cjs +0 -31
- package/dist/shared/llm/index.d.cts +0 -5
- package/dist/shared/llm/types.cjs +0 -16
- package/dist/shared/llm/types.d.cts +0 -67
- package/dist/shared/logger/index.cjs +0 -37
- package/dist/shared/logger/index.d.cts +0 -2
- package/dist/shared/logger/logger.cjs +0 -232
- package/dist/shared/logger/logger.d.cts +0 -86
- package/dist/shared/logger/sinks.cjs +0 -160
- package/dist/shared/logger/sinks.d.cts +0 -9
- package/dist/shared/paths/paths.cjs +0 -104
- package/dist/shared/paths/paths.d.cts +0 -10
- package/dist/shared/run/api.cjs +0 -28
- package/dist/shared/run/api.d.cts +0 -2
- package/dist/shared/run/browser.cjs +0 -98
- package/dist/shared/run/browser.d.cts +0 -22
- package/dist/shared/state/index.cjs +0 -38
- package/dist/shared/state/index.d.cts +0 -2
- package/dist/shared/state/session-state.cjs +0 -92
- package/dist/shared/state/session-state.d.cts +0 -40
- package/dist/shared/visualization/ghost-cursor.cjs +0 -174
- package/dist/shared/visualization/ghost-cursor.d.cts +0 -37
- package/dist/shared/visualization/highlight.cjs +0 -134
- package/dist/shared/visualization/highlight.d.cts +0 -22
- package/dist/shared/visualization/index.cjs +0 -45
- package/dist/shared/visualization/index.d.cts +0 -3
- package/dist/shared/workflow/workflow.cjs +0 -47
- package/dist/shared/workflow/workflow.d.cts +0 -21
- package/skills/libretto/code-generation-rules.md +0 -223
- package/skills/libretto/integration-approach-selection.md +0 -174
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime configuration for libretto.
|
|
3
|
+
*
|
|
4
|
+
* Values are derived from environment variables only.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function isDebugMode(): boolean {
|
|
8
|
+
return process.env.LIBRETTO_DEBUG === "true";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isDryRun(): boolean {
|
|
12
|
+
const explicit = process.env.LIBRETTO_DRY_RUN;
|
|
13
|
+
if (explicit !== undefined) {
|
|
14
|
+
return explicit === "true";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return process.env.NODE_ENV === "development";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shouldPauseBeforeMutation(): boolean {
|
|
21
|
+
return isDryRun() && isDebugMode();
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { pause } from "./pause.js";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { getSessionDir } from "../../cli/core/context.js";
|
|
4
|
+
import { getPauseSignalPaths, removeSignalIfExists } from "../../cli/core/pause-signals.js";
|
|
5
|
+
import { listSessionsWithStateFile, readSessionState } from "../../cli/core/session.js";
|
|
6
|
+
|
|
7
|
+
function isPidRunning(pid: number): boolean {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getRunningSessions(): string[] {
|
|
17
|
+
return listSessionsWithStateFile().filter((candidate) => {
|
|
18
|
+
const state = readSessionState(candidate);
|
|
19
|
+
return state !== null && isPidRunning(state.pid);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function throwMissingSessionError(): never {
|
|
24
|
+
const runningSessions = getRunningSessions();
|
|
25
|
+
const lines = ["pause(session) requires a non-empty session ID."];
|
|
26
|
+
|
|
27
|
+
if (runningSessions.length > 0) {
|
|
28
|
+
lines.push("", "Running sessions:");
|
|
29
|
+
for (const runningSession of runningSessions) {
|
|
30
|
+
lines.push(` ${runningSession}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new Error(lines.join("\n"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Standalone pause function.
|
|
39
|
+
*
|
|
40
|
+
* - In production (`NODE_ENV === "production"`), returns immediately (no-op).
|
|
41
|
+
* - Otherwise, writes a `.paused` signal file and polls for a `.resume` signal,
|
|
42
|
+
* using the same file-based mechanism as the CLI runner.
|
|
43
|
+
*
|
|
44
|
+
* Import directly: `import { pause } from "libretto";`
|
|
45
|
+
*/
|
|
46
|
+
export async function pause(session: string): Promise<void> {
|
|
47
|
+
if (process.env.NODE_ENV === "production") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof session !== "string" || session.trim().length === 0) {
|
|
52
|
+
throwMissingSessionError();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const signalPaths = getPauseSignalPaths(session);
|
|
56
|
+
const { pausedSignalPath, resumeSignalPath } = signalPaths;
|
|
57
|
+
|
|
58
|
+
await mkdir(getSessionDir(session), { recursive: true });
|
|
59
|
+
await removeSignalIfExists(resumeSignalPath);
|
|
60
|
+
|
|
61
|
+
const details = {
|
|
62
|
+
sessionName: session,
|
|
63
|
+
pausedAt: new Date().toISOString(),
|
|
64
|
+
url: "unknown",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Try to read the current page URL from the process (best-effort).
|
|
68
|
+
// The standalone pause doesn't have access to the page object,
|
|
69
|
+
// so we just record what we can.
|
|
70
|
+
await writeFile(pausedSignalPath, JSON.stringify(details, null, 2), "utf8");
|
|
71
|
+
|
|
72
|
+
console.log(`[pause] Paused (session: ${session})`);
|
|
73
|
+
console.log("[pause] Waiting for resume signal...");
|
|
74
|
+
|
|
75
|
+
const RESUME_POLL_INTERVAL_MS = 250;
|
|
76
|
+
while (!existsSync(resumeSignalPath)) {
|
|
77
|
+
await new Promise((resolve) =>
|
|
78
|
+
setTimeout(resolve, RESUME_POLL_INTERVAL_MS),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await removeSignalIfExists(resumeSignalPath);
|
|
83
|
+
await removeSignalIfExists(pausedSignalPath);
|
|
84
|
+
console.log("[pause] Resume signal received. Continuing workflow...");
|
|
85
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Page, Locator } from "playwright";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enrich a timeout error from a pointer action (click/dblclick/hover) with
|
|
5
|
+
* diagnostic information about why the action may have failed.
|
|
6
|
+
*
|
|
7
|
+
* Mutates err.message in-place to append the enrichment.
|
|
8
|
+
* Best-effort: if any probe fails, we skip that check silently.
|
|
9
|
+
*/
|
|
10
|
+
export async function enrichTimeoutError(
|
|
11
|
+
err: any,
|
|
12
|
+
locator: Locator,
|
|
13
|
+
page: Page,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const reasons: string[] = [];
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const visible = await locator.isVisible().catch(() => null);
|
|
19
|
+
if (visible === false) {
|
|
20
|
+
reasons.push("Element is not visible");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// isInViewport is available in modern Playwright but may not exist in older versions
|
|
24
|
+
if (typeof (locator as any).isInViewport === "function") {
|
|
25
|
+
const inViewport = await (locator as any).isInViewport().catch(() => null);
|
|
26
|
+
if (inViewport === false) {
|
|
27
|
+
reasons.push("Element is outside of the viewport");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const enabled = await locator.isEnabled().catch(() => null);
|
|
32
|
+
if (enabled === false) {
|
|
33
|
+
reasons.push("Element is not enabled (disabled)");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If the element appears visible and in viewport, check for intercepting elements
|
|
37
|
+
if (reasons.length === 0) {
|
|
38
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
39
|
+
if (box) {
|
|
40
|
+
const centerX = box.x + box.width / 2;
|
|
41
|
+
const centerY = box.y + box.height / 2;
|
|
42
|
+
|
|
43
|
+
const interceptInfo = await page
|
|
44
|
+
.evaluate(
|
|
45
|
+
({ x, y }) => {
|
|
46
|
+
const els = document.elementsFromPoint(x, y);
|
|
47
|
+
if (!els || els.length < 2) return null;
|
|
48
|
+
const topEl = els[0];
|
|
49
|
+
if (!topEl) return null;
|
|
50
|
+
|
|
51
|
+
// Build a brief preview of the intercepting element
|
|
52
|
+
const tag = topEl.tagName.toLowerCase();
|
|
53
|
+
const id = topEl.id ? `#${topEl.id}` : "";
|
|
54
|
+
const cls = topEl.className
|
|
55
|
+
? `.${String(topEl.className).split(/\s+/).slice(0, 2).join(".")}`
|
|
56
|
+
: "";
|
|
57
|
+
const text = (topEl.textContent || "").trim().slice(0, 40);
|
|
58
|
+
return {
|
|
59
|
+
tag,
|
|
60
|
+
preview: `<${tag}${id}${cls}>${text ? ` "${text}"` : ""}`,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
{ x: centerX, y: centerY },
|
|
64
|
+
)
|
|
65
|
+
.catch(() => null);
|
|
66
|
+
|
|
67
|
+
if (interceptInfo) {
|
|
68
|
+
reasons.push(
|
|
69
|
+
`Element may be intercepted by ${interceptInfo.preview}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// All enrichment is best-effort
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (reasons.length > 0) {
|
|
79
|
+
const enrichment = `\n[libretto diagnostics] ${reasons.join("; ")}`;
|
|
80
|
+
err.message = (err.message || "") + enrichment;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { Page, Locator, BrowserContext } from "playwright";
|
|
2
|
+
import type { MinimalLogger } from "../logger/logger.js";
|
|
3
|
+
import type { GhostCursorOptions } from "../visualization/ghost-cursor.js";
|
|
4
|
+
import type { HighlightOptions } from "../visualization/highlight.js";
|
|
5
|
+
import {
|
|
6
|
+
ensureGhostCursor,
|
|
7
|
+
moveGhostCursor,
|
|
8
|
+
moveGhostCursorWithDistance,
|
|
9
|
+
ghostClick,
|
|
10
|
+
getGhostCursorPosition,
|
|
11
|
+
} from "../visualization/ghost-cursor.js";
|
|
12
|
+
import {
|
|
13
|
+
ensureHighlightLayer,
|
|
14
|
+
showHighlight,
|
|
15
|
+
clearHighlights,
|
|
16
|
+
} from "../visualization/highlight.js";
|
|
17
|
+
import { enrichTimeoutError } from "./errors.js";
|
|
18
|
+
|
|
19
|
+
export type InstrumentationOptions = {
|
|
20
|
+
visualize?: boolean;
|
|
21
|
+
logger?: MinimalLogger;
|
|
22
|
+
highlightBeforeActionMs?: number;
|
|
23
|
+
ghostCursor?: GhostCursorOptions;
|
|
24
|
+
highlight?: HighlightOptions;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type InstrumentedPage = Page & {
|
|
28
|
+
__librettoInstrumented: true;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const LOCATOR_ACTIONS = [
|
|
32
|
+
"click",
|
|
33
|
+
"dblclick",
|
|
34
|
+
"hover",
|
|
35
|
+
"fill",
|
|
36
|
+
"type",
|
|
37
|
+
"press",
|
|
38
|
+
"check",
|
|
39
|
+
"uncheck",
|
|
40
|
+
"selectOption",
|
|
41
|
+
"focus",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
const NAV_ACTIONS = ["goto", "reload", "goBack", "goForward"] as const;
|
|
45
|
+
|
|
46
|
+
const POINTER_ACTIONS = new Set<string>(["click", "dblclick", "hover"]);
|
|
47
|
+
|
|
48
|
+
// Per-page serialization queue so overlapping visualization actions don't glitch
|
|
49
|
+
const pageQueues = new WeakMap<Page, Promise<void>>();
|
|
50
|
+
|
|
51
|
+
function enqueue(page: Page, fn: () => Promise<void>): Promise<void> {
|
|
52
|
+
const prev = pageQueues.get(page) ?? Promise.resolve();
|
|
53
|
+
const next = prev.then(fn, fn);
|
|
54
|
+
pageQueues.set(page, next);
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function visualizeBeforeAction(
|
|
59
|
+
page: Page,
|
|
60
|
+
box: { x: number; y: number; width: number; height: number } | null,
|
|
61
|
+
actionName: string,
|
|
62
|
+
highlightMs: number,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
if (!box) return;
|
|
65
|
+
|
|
66
|
+
// Re-ensure overlays in case DOM was replaced (e.g. page.setContent()).
|
|
67
|
+
await ensureGhostCursor(page);
|
|
68
|
+
await ensureHighlightLayer(page);
|
|
69
|
+
|
|
70
|
+
const centerX = box.x + box.width / 2;
|
|
71
|
+
const centerY = box.y + box.height / 2;
|
|
72
|
+
|
|
73
|
+
// Show highlight on the target element
|
|
74
|
+
await showHighlight(page, {
|
|
75
|
+
box,
|
|
76
|
+
durationMs: highlightMs + 200, // keep visible a bit past the cursor arrival
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Move ghost cursor to target
|
|
80
|
+
const cursorPos = await getGhostCursorPosition(page);
|
|
81
|
+
if (cursorPos) {
|
|
82
|
+
await moveGhostCursorWithDistance(page, cursorPos, {
|
|
83
|
+
x: centerX,
|
|
84
|
+
y: centerY,
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
await moveGhostCursor(page, { x: centerX, y: centerY, durationMs: 200 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// For click actions, show click feedback
|
|
91
|
+
if (actionName === "click" || actionName === "dblclick") {
|
|
92
|
+
await ghostClick(page, { x: centerX, y: centerY });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function visualizeAfterAction(page: Page): Promise<void> {
|
|
97
|
+
await clearHighlights(page);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function wrapLocatorActions(
|
|
101
|
+
locator: Locator,
|
|
102
|
+
page: Page,
|
|
103
|
+
opts: Required<Pick<InstrumentationOptions, "visualize" | "highlightBeforeActionMs">> & InstrumentationOptions,
|
|
104
|
+
): void {
|
|
105
|
+
for (const method of LOCATOR_ACTIONS) {
|
|
106
|
+
if (typeof (locator as any)[method] !== "function") continue;
|
|
107
|
+
const orig = (locator as any)[method].bind(locator);
|
|
108
|
+
|
|
109
|
+
(locator as any)[method] = async (...args: any[]) => {
|
|
110
|
+
if (opts.visualize) {
|
|
111
|
+
await enqueue(page, async () => {
|
|
112
|
+
try {
|
|
113
|
+
const box = await locator.boundingBox();
|
|
114
|
+
await visualizeBeforeAction(
|
|
115
|
+
page,
|
|
116
|
+
box,
|
|
117
|
+
method,
|
|
118
|
+
opts.highlightBeforeActionMs,
|
|
119
|
+
);
|
|
120
|
+
} catch {
|
|
121
|
+
// Best-effort visualization
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const result = await orig(...args);
|
|
128
|
+
if (opts.visualize) {
|
|
129
|
+
enqueue(page, () => visualizeAfterAction(page));
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
if (opts.visualize) {
|
|
134
|
+
enqueue(page, () => visualizeAfterAction(page));
|
|
135
|
+
}
|
|
136
|
+
// Enrich timeout errors for pointer actions
|
|
137
|
+
if (POINTER_ACTIONS.has(method) && isTimeoutError(err)) {
|
|
138
|
+
await enrichTimeoutError(err, locator, page);
|
|
139
|
+
}
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isTimeoutError(err: any): boolean {
|
|
147
|
+
if (!err || typeof err.message !== "string") return false;
|
|
148
|
+
return (
|
|
149
|
+
err.message.includes("Timeout") ||
|
|
150
|
+
err.message.includes("timeout") ||
|
|
151
|
+
err.name === "TimeoutError"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const LOCATOR_FACTORIES = [
|
|
156
|
+
"locator",
|
|
157
|
+
"getByRole",
|
|
158
|
+
"getByText",
|
|
159
|
+
"getByLabel",
|
|
160
|
+
"getByPlaceholder",
|
|
161
|
+
"getByAltText",
|
|
162
|
+
"getByTitle",
|
|
163
|
+
"getByTestId",
|
|
164
|
+
] as const;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* In-place patching of a Page object to add visualization and error enrichment.
|
|
168
|
+
* Modifies the page directly (does not return a new object).
|
|
169
|
+
*/
|
|
170
|
+
export async function installInstrumentation(
|
|
171
|
+
page: Page,
|
|
172
|
+
options?: InstrumentationOptions,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
if ((page as any).__librettoInstrumented) return;
|
|
175
|
+
(page as any).__librettoInstrumented = true;
|
|
176
|
+
|
|
177
|
+
const visualize = options?.visualize ?? false;
|
|
178
|
+
const highlightBeforeActionMs = options?.highlightBeforeActionMs ?? 350;
|
|
179
|
+
const mergedOpts = { ...options, visualize, highlightBeforeActionMs };
|
|
180
|
+
|
|
181
|
+
// Install overlay layers if visualization is on
|
|
182
|
+
if (visualize) {
|
|
183
|
+
await ensureGhostCursor(page, options?.ghostCursor);
|
|
184
|
+
await ensureHighlightLayer(page, options?.highlight);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Wrap page-level locator actions (page.click, page.fill, etc.)
|
|
188
|
+
for (const method of LOCATOR_ACTIONS) {
|
|
189
|
+
if (typeof (page as any)[method] !== "function") continue;
|
|
190
|
+
const orig = (page as any)[method].bind(page);
|
|
191
|
+
(page as any)[method] = async (...args: any[]) => {
|
|
192
|
+
// For page-level actions, the first arg is typically the selector
|
|
193
|
+
if (visualize && typeof args[0] === "string") {
|
|
194
|
+
await enqueue(page, async () => {
|
|
195
|
+
try {
|
|
196
|
+
const loc = page.locator(args[0]);
|
|
197
|
+
const box = await loc.boundingBox();
|
|
198
|
+
await visualizeBeforeAction(page, box, method, highlightBeforeActionMs);
|
|
199
|
+
} catch {
|
|
200
|
+
// Best-effort
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const result = await orig(...args);
|
|
207
|
+
if (visualize) {
|
|
208
|
+
enqueue(page, () => visualizeAfterAction(page));
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
} catch (err: any) {
|
|
212
|
+
if (visualize) {
|
|
213
|
+
enqueue(page, () => visualizeAfterAction(page));
|
|
214
|
+
}
|
|
215
|
+
if (POINTER_ACTIONS.has(method) && isTimeoutError(err) && typeof args[0] === "string") {
|
|
216
|
+
await enrichTimeoutError(err, page.locator(args[0]), page);
|
|
217
|
+
}
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Wrap navigation actions (no visualization, just logging)
|
|
224
|
+
for (const method of NAV_ACTIONS) {
|
|
225
|
+
if (typeof (page as any)[method] !== "function") continue;
|
|
226
|
+
const orig = (page as any)[method].bind(page);
|
|
227
|
+
(page as any)[method] = async (...args: any[]) => {
|
|
228
|
+
options?.logger?.info(`instrumentation:${method}`, {
|
|
229
|
+
url: typeof args[0] === "string" ? args[0] : page.url(),
|
|
230
|
+
});
|
|
231
|
+
return orig(...args);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Wrap locator factories to instrument returned locators
|
|
236
|
+
for (const factory of LOCATOR_FACTORIES) {
|
|
237
|
+
if (typeof (page as any)[factory] !== "function") continue;
|
|
238
|
+
const origFactory = (page as any)[factory].bind(page);
|
|
239
|
+
(page as any)[factory] = (...factoryArgs: any[]) => {
|
|
240
|
+
const locator = origFactory(...factoryArgs);
|
|
241
|
+
wrapLocatorActions(locator, page, mergedOpts);
|
|
242
|
+
return locator;
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Returns a new object that proxies to the page with instrumentation applied.
|
|
249
|
+
* The original page is not modified.
|
|
250
|
+
*/
|
|
251
|
+
export async function instrumentPage(
|
|
252
|
+
page: Page,
|
|
253
|
+
options?: InstrumentationOptions,
|
|
254
|
+
): Promise<InstrumentedPage> {
|
|
255
|
+
await installInstrumentation(page, options);
|
|
256
|
+
return page as InstrumentedPage;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Install overlays on a page and auto-install on all future pages in the context.
|
|
261
|
+
* Useful when connecting to an existing browser via CDP.
|
|
262
|
+
*/
|
|
263
|
+
export async function instrumentContext(
|
|
264
|
+
context: BrowserContext,
|
|
265
|
+
options?: InstrumentationOptions,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
// Instrument all existing pages
|
|
268
|
+
for (const page of context.pages()) {
|
|
269
|
+
await installInstrumentation(page, options);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Auto-instrument new pages
|
|
273
|
+
context.on("page", async (page) => {
|
|
274
|
+
await installInstrumentation(page, options);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { generateObject, type LanguageModel } from "ai";
|
|
2
|
+
import type { ZodType, output as ZodOutput } from "zod";
|
|
3
|
+
import type { LLMClient, Message } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a libretto LLMClient from a Vercel AI SDK LanguageModel.
|
|
7
|
+
*
|
|
8
|
+
* This eliminates the need for consumers to write their own adapter
|
|
9
|
+
* when using @ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google-vertex,
|
|
10
|
+
* or any other Vercel AI SDK-compatible provider.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { createLLMClientFromModel } from "libretto/llm";
|
|
15
|
+
* import { openai } from "@ai-sdk/openai";
|
|
16
|
+
*
|
|
17
|
+
* const llmClient = createLLMClientFromModel(openai("gpt-4o"));
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function createLLMClientFromModel(model: LanguageModel): LLMClient {
|
|
21
|
+
return {
|
|
22
|
+
async generateObject<T extends ZodType>(opts: {
|
|
23
|
+
prompt: string;
|
|
24
|
+
schema: T;
|
|
25
|
+
temperature?: number;
|
|
26
|
+
}): Promise<ZodOutput<T>> {
|
|
27
|
+
const { object } = await generateObject({
|
|
28
|
+
model,
|
|
29
|
+
schema: opts.schema,
|
|
30
|
+
prompt: opts.prompt,
|
|
31
|
+
temperature: opts.temperature ?? 0,
|
|
32
|
+
});
|
|
33
|
+
return object as ZodOutput<T>;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async generateObjectFromMessages<T extends ZodType>(opts: {
|
|
37
|
+
messages: Message[];
|
|
38
|
+
schema: T;
|
|
39
|
+
temperature?: number;
|
|
40
|
+
}): Promise<ZodOutput<T>> {
|
|
41
|
+
// Convert libretto Message format to AI SDK message format
|
|
42
|
+
const messages = opts.messages.map((msg) => {
|
|
43
|
+
if (typeof msg.content === "string") {
|
|
44
|
+
return { role: msg.role, content: msg.content };
|
|
45
|
+
}
|
|
46
|
+
if (msg.role === "assistant") {
|
|
47
|
+
// AssistantContent only supports text parts (no images)
|
|
48
|
+
return {
|
|
49
|
+
role: "assistant" as const,
|
|
50
|
+
content: msg.content
|
|
51
|
+
.filter((part): part is typeof part & { type: "text" } => part.type === "text")
|
|
52
|
+
.map((part) => ({ type: "text" as const, text: part.text })),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
role: "user" as const,
|
|
57
|
+
content: msg.content.map((part) =>
|
|
58
|
+
part.type === "text"
|
|
59
|
+
? { type: "text" as const, text: part.text }
|
|
60
|
+
: {
|
|
61
|
+
type: "image" as const,
|
|
62
|
+
image: part.image,
|
|
63
|
+
...(part.mediaType ? { mediaType: part.mediaType } : {}),
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const { object } = await generateObject({
|
|
70
|
+
model,
|
|
71
|
+
schema: opts.schema,
|
|
72
|
+
messages,
|
|
73
|
+
temperature: opts.temperature ?? 0,
|
|
74
|
+
});
|
|
75
|
+
return object as ZodOutput<T>;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|