libretto 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +106 -36
  2. package/dist/cli/cli.js +39 -113
  3. package/dist/cli/commands/ai.js +1 -1
  4. package/dist/cli/commands/browser.js +87 -60
  5. package/dist/cli/commands/execution.js +201 -88
  6. package/dist/cli/commands/init.js +30 -8
  7. package/dist/cli/commands/logs.js +5 -6
  8. package/dist/cli/commands/shared.js +30 -29
  9. package/dist/cli/commands/snapshot.js +26 -39
  10. package/dist/cli/core/ai-config.js +9 -2
  11. package/dist/cli/core/api-snapshot-analyzer.js +15 -5
  12. package/dist/cli/core/browser.js +141 -33
  13. package/dist/cli/core/context.js +7 -18
  14. package/dist/cli/core/session-telemetry.js +5 -2
  15. package/dist/cli/core/session.js +23 -10
  16. package/dist/cli/core/snapshot-analyzer.js +16 -33
  17. package/dist/cli/core/snapshot-api-config.js +2 -6
  18. package/dist/cli/core/telemetry.js +10 -2
  19. package/dist/cli/framework/simple-cli.js +45 -25
  20. package/dist/cli/router.js +14 -21
  21. package/dist/cli/workers/run-integration-runtime.js +26 -7
  22. package/dist/cli/workers/run-integration-worker-protocol.js +3 -1
  23. package/dist/cli/workers/run-integration-worker.js +1 -4
  24. package/dist/index.d.ts +1 -2
  25. package/dist/index.js +7 -10
  26. package/dist/runtime/download/download.js +5 -1
  27. package/dist/runtime/extract/extract.js +11 -2
  28. package/dist/runtime/network/network.js +8 -1
  29. package/dist/runtime/recovery/agent.js +6 -2
  30. package/dist/runtime/recovery/errors.js +3 -1
  31. package/dist/runtime/recovery/recovery.js +3 -1
  32. package/dist/shared/condense-dom/condense-dom.js +6 -13
  33. package/dist/shared/config/config.d.ts +1 -9
  34. package/dist/shared/config/config.js +0 -18
  35. package/dist/shared/config/index.d.ts +2 -1
  36. package/dist/shared/config/index.js +0 -10
  37. package/dist/shared/debug/pause.js +9 -3
  38. package/dist/shared/instrumentation/instrument.js +101 -5
  39. package/dist/shared/llm/ai-sdk-adapter.js +3 -1
  40. package/dist/shared/llm/client.js +3 -1
  41. package/dist/shared/logger/index.js +4 -1
  42. package/dist/shared/paths/paths.js +2 -1
  43. package/dist/shared/paths/repo-root.d.ts +3 -0
  44. package/dist/shared/paths/repo-root.js +24 -0
  45. package/dist/shared/run/api.js +3 -1
  46. package/dist/shared/run/browser.js +7 -2
  47. package/dist/shared/state/session-state.d.ts +2 -1
  48. package/dist/shared/state/session-state.js +5 -2
  49. package/dist/shared/visualization/ghost-cursor.js +19 -10
  50. package/dist/shared/visualization/highlight.js +9 -6
  51. package/dist/shared/workflow/workflow.d.ts +4 -5
  52. package/dist/shared/workflow/workflow.js +3 -5
  53. package/package.json +11 -8
  54. package/scripts/check-skills-sync.mjs +25 -0
  55. package/scripts/compare-eval-summary.mjs +47 -0
  56. package/scripts/postinstall.mjs +26 -17
  57. package/scripts/prepare-release.sh +97 -0
  58. package/scripts/skills-libretto.mjs +103 -0
  59. package/scripts/summarize-evals.mjs +135 -0
  60. package/scripts/sync-skills.mjs +12 -0
  61. package/skills/libretto/SKILL.md +130 -377
  62. package/skills/libretto/references/auth-profiles.md +30 -0
  63. package/skills/libretto/{code-generation-rules.md → references/code-generation-rules.md} +27 -42
  64. package/skills/libretto/references/configuration-file-reference.md +53 -0
  65. package/skills/libretto/references/pages-and-page-targeting.md +29 -0
  66. package/skills/libretto/references/site-security-review.md +143 -0
  67. package/src/cli/cli.ts +86 -0
  68. package/src/cli/commands/ai.ts +35 -0
  69. package/src/cli/commands/browser.ts +189 -0
  70. package/src/cli/commands/execution.ts +822 -0
  71. package/src/cli/commands/init.ts +350 -0
  72. package/src/cli/commands/logs.ts +128 -0
  73. package/src/cli/commands/shared.ts +69 -0
  74. package/src/cli/commands/snapshot.ts +312 -0
  75. package/src/cli/core/ai-config.ts +264 -0
  76. package/src/cli/core/api-snapshot-analyzer.ts +108 -0
  77. package/src/cli/core/browser.ts +976 -0
  78. package/src/cli/core/context.ts +127 -0
  79. package/src/cli/core/pause-signals.ts +35 -0
  80. package/src/cli/core/session-telemetry.ts +564 -0
  81. package/src/cli/core/session.ts +223 -0
  82. package/src/cli/core/snapshot-analyzer.ts +855 -0
  83. package/src/cli/core/snapshot-api-config.ts +231 -0
  84. package/src/cli/core/telemetry.ts +459 -0
  85. package/src/cli/framework/simple-cli.ts +1340 -0
  86. package/src/cli/index.ts +13 -0
  87. package/src/cli/router.ts +20 -0
  88. package/src/cli/workers/run-integration-runtime.ts +338 -0
  89. package/src/cli/workers/run-integration-worker-protocol.ts +16 -0
  90. package/src/cli/workers/run-integration-worker.ts +72 -0
  91. package/src/index.ts +127 -0
  92. package/src/runtime/download/download.ts +104 -0
  93. package/src/runtime/download/index.ts +7 -0
  94. package/src/runtime/extract/extract.ts +102 -0
  95. package/src/runtime/extract/index.ts +1 -0
  96. package/src/runtime/network/index.ts +5 -0
  97. package/src/runtime/network/network.ts +119 -0
  98. package/{dist/runtime/recovery/agent.cjs → src/runtime/recovery/agent.ts} +114 -76
  99. package/src/runtime/recovery/errors.ts +155 -0
  100. package/src/runtime/recovery/index.ts +7 -0
  101. package/src/runtime/recovery/recovery.ts +53 -0
  102. package/{dist/shared/condense-dom/condense-dom.cjs → src/shared/condense-dom/condense-dom.ts} +249 -124
  103. package/src/shared/config/config.ts +3 -0
  104. package/src/shared/config/index.ts +0 -0
  105. package/src/shared/debug/index.ts +1 -0
  106. package/src/shared/debug/pause.ts +91 -0
  107. package/src/shared/instrumentation/errors.ts +84 -0
  108. package/src/shared/instrumentation/index.ts +9 -0
  109. package/src/shared/instrumentation/instrument.ts +406 -0
  110. package/src/shared/llm/ai-sdk-adapter.ts +81 -0
  111. package/{dist/shared/llm/client.cjs → src/shared/llm/client.ts} +86 -80
  112. package/src/shared/llm/index.ts +3 -0
  113. package/src/shared/llm/types.ts +63 -0
  114. package/src/shared/logger/index.ts +13 -0
  115. package/src/shared/logger/logger.ts +358 -0
  116. package/src/shared/logger/sinks.ts +148 -0
  117. package/src/shared/paths/paths.ts +110 -0
  118. package/src/shared/paths/repo-root.ts +27 -0
  119. package/src/shared/run/api.ts +6 -0
  120. package/src/shared/run/browser.ts +107 -0
  121. package/src/shared/state/index.ts +11 -0
  122. package/src/shared/state/session-state.ts +77 -0
  123. package/src/shared/visualization/ghost-cursor.ts +213 -0
  124. package/src/shared/visualization/highlight.ts +149 -0
  125. package/src/shared/visualization/index.ts +18 -0
  126. package/src/shared/workflow/workflow.ts +36 -0
  127. package/dist/index.cjs +0 -144
  128. package/dist/index.d.cts +0 -21
  129. package/dist/runtime/download/download.cjs +0 -70
  130. package/dist/runtime/download/download.d.cts +0 -35
  131. package/dist/runtime/download/index.cjs +0 -30
  132. package/dist/runtime/download/index.d.cts +0 -3
  133. package/dist/runtime/extract/extract.cjs +0 -88
  134. package/dist/runtime/extract/extract.d.cts +0 -23
  135. package/dist/runtime/extract/index.cjs +0 -28
  136. package/dist/runtime/extract/index.d.cts +0 -5
  137. package/dist/runtime/network/index.cjs +0 -28
  138. package/dist/runtime/network/index.d.cts +0 -4
  139. package/dist/runtime/network/network.cjs +0 -91
  140. package/dist/runtime/network/network.d.cts +0 -28
  141. package/dist/runtime/recovery/agent.d.cts +0 -13
  142. package/dist/runtime/recovery/errors.cjs +0 -124
  143. package/dist/runtime/recovery/errors.d.cts +0 -31
  144. package/dist/runtime/recovery/index.cjs +0 -34
  145. package/dist/runtime/recovery/index.d.cts +0 -7
  146. package/dist/runtime/recovery/recovery.cjs +0 -55
  147. package/dist/runtime/recovery/recovery.d.cts +0 -12
  148. package/dist/shared/condense-dom/condense-dom.d.cts +0 -34
  149. package/dist/shared/config/config.cjs +0 -44
  150. package/dist/shared/config/config.d.cts +0 -10
  151. package/dist/shared/config/index.cjs +0 -32
  152. package/dist/shared/config/index.d.cts +0 -1
  153. package/dist/shared/debug/index.cjs +0 -28
  154. package/dist/shared/debug/index.d.cts +0 -1
  155. package/dist/shared/debug/pause.cjs +0 -86
  156. package/dist/shared/debug/pause.d.cts +0 -12
  157. package/dist/shared/instrumentation/errors.cjs +0 -81
  158. package/dist/shared/instrumentation/errors.d.cts +0 -12
  159. package/dist/shared/instrumentation/index.cjs +0 -35
  160. package/dist/shared/instrumentation/index.d.cts +0 -6
  161. package/dist/shared/instrumentation/instrument.cjs +0 -206
  162. package/dist/shared/instrumentation/instrument.d.cts +0 -32
  163. package/dist/shared/llm/ai-sdk-adapter.cjs +0 -71
  164. package/dist/shared/llm/ai-sdk-adapter.d.cts +0 -22
  165. package/dist/shared/llm/client.d.cts +0 -13
  166. package/dist/shared/llm/index.cjs +0 -31
  167. package/dist/shared/llm/index.d.cts +0 -5
  168. package/dist/shared/llm/types.cjs +0 -16
  169. package/dist/shared/llm/types.d.cts +0 -67
  170. package/dist/shared/logger/index.cjs +0 -37
  171. package/dist/shared/logger/index.d.cts +0 -2
  172. package/dist/shared/logger/logger.cjs +0 -232
  173. package/dist/shared/logger/logger.d.cts +0 -86
  174. package/dist/shared/logger/sinks.cjs +0 -160
  175. package/dist/shared/logger/sinks.d.cts +0 -9
  176. package/dist/shared/paths/paths.cjs +0 -104
  177. package/dist/shared/paths/paths.d.cts +0 -10
  178. package/dist/shared/run/api.cjs +0 -28
  179. package/dist/shared/run/api.d.cts +0 -2
  180. package/dist/shared/run/browser.cjs +0 -98
  181. package/dist/shared/run/browser.d.cts +0 -22
  182. package/dist/shared/state/index.cjs +0 -38
  183. package/dist/shared/state/index.d.cts +0 -2
  184. package/dist/shared/state/session-state.cjs +0 -92
  185. package/dist/shared/state/session-state.d.cts +0 -40
  186. package/dist/shared/visualization/ghost-cursor.cjs +0 -174
  187. package/dist/shared/visualization/ghost-cursor.d.cts +0 -37
  188. package/dist/shared/visualization/highlight.cjs +0 -134
  189. package/dist/shared/visualization/highlight.d.cts +0 -22
  190. package/dist/shared/visualization/index.cjs +0 -45
  191. package/dist/shared/visualization/index.d.cts +0 -3
  192. package/dist/shared/workflow/workflow.cjs +0 -47
  193. package/dist/shared/workflow/workflow.d.cts +0 -21
  194. package/skills/libretto/integration-approach-selection.md +0 -174
@@ -0,0 +1,3 @@
1
+ /**
2
+ * Runtime configuration for libretto.
3
+ */
File without changes
@@ -0,0 +1 @@
1
+ export { pause } from "./pause.js";
@@ -0,0 +1,91 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { getSessionDir } from "../../cli/core/context.js";
4
+ import {
5
+ getPauseSignalPaths,
6
+ removeSignalIfExists,
7
+ } from "../../cli/core/pause-signals.js";
8
+ import {
9
+ listSessionsWithStateFile,
10
+ readSessionState,
11
+ } from "../../cli/core/session.js";
12
+
13
+ function isPidRunning(pid: number): boolean {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function getRunningSessions(): string[] {
23
+ return listSessionsWithStateFile().filter((candidate) => {
24
+ const state = readSessionState(candidate);
25
+ return state !== null && state.pid != null && isPidRunning(state.pid);
26
+ });
27
+ }
28
+
29
+ function throwMissingSessionError(): never {
30
+ const runningSessions = getRunningSessions();
31
+ const lines = ["pause(session) requires a non-empty session ID."];
32
+
33
+ if (runningSessions.length > 0) {
34
+ lines.push("", "Running sessions:");
35
+ for (const runningSession of runningSessions) {
36
+ lines.push(` ${runningSession}`);
37
+ }
38
+ }
39
+
40
+ throw new Error(lines.join("\n"));
41
+ }
42
+
43
+ /**
44
+ * Standalone pause function.
45
+ *
46
+ * - In production (`NODE_ENV === "production"`), returns immediately (no-op).
47
+ * - Otherwise, writes a `.paused` signal file and polls for a `.resume` signal,
48
+ * using the same file-based mechanism as the CLI runner.
49
+ *
50
+ * Import directly: `import { pause } from "libretto";`
51
+ */
52
+ export async function pause(session: string): Promise<void> {
53
+ if (process.env.NODE_ENV === "production") {
54
+ return;
55
+ }
56
+
57
+ if (typeof session !== "string" || session.trim().length === 0) {
58
+ throwMissingSessionError();
59
+ }
60
+
61
+ const signalPaths = getPauseSignalPaths(session);
62
+ const { pausedSignalPath, resumeSignalPath } = signalPaths;
63
+
64
+ await mkdir(getSessionDir(session), { recursive: true });
65
+ await removeSignalIfExists(resumeSignalPath);
66
+
67
+ const details = {
68
+ sessionName: session,
69
+ pausedAt: new Date().toISOString(),
70
+ url: "unknown",
71
+ };
72
+
73
+ // Try to read the current page URL from the process (best-effort).
74
+ // The standalone pause doesn't have access to the page object,
75
+ // so we just record what we can.
76
+ await writeFile(pausedSignalPath, JSON.stringify(details, null, 2), "utf8");
77
+
78
+ console.log(`[pause] Paused (session: ${session})`);
79
+ console.log("[pause] Waiting for resume signal...");
80
+
81
+ const RESUME_POLL_INTERVAL_MS = 250;
82
+ while (!existsSync(resumeSignalPath)) {
83
+ await new Promise((resolve) =>
84
+ setTimeout(resolve, RESUME_POLL_INTERVAL_MS),
85
+ );
86
+ }
87
+
88
+ await removeSignalIfExists(resumeSignalPath);
89
+ await removeSignalIfExists(pausedSignalPath);
90
+ console.log("[pause] Resume signal received. Continuing workflow...");
91
+ }
@@ -0,0 +1,84 @@
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)
26
+ .isInViewport()
27
+ .catch(() => null);
28
+ if (inViewport === false) {
29
+ reasons.push("Element is outside of the viewport");
30
+ }
31
+ }
32
+
33
+ const enabled = await locator.isEnabled().catch(() => null);
34
+ if (enabled === false) {
35
+ reasons.push("Element is not enabled (disabled)");
36
+ }
37
+
38
+ // If the element appears visible and in viewport, check for intercepting elements
39
+ if (reasons.length === 0) {
40
+ const box = await locator.boundingBox().catch(() => null);
41
+ if (box) {
42
+ const centerX = box.x + box.width / 2;
43
+ const centerY = box.y + box.height / 2;
44
+
45
+ const interceptInfo = await page
46
+ .evaluate(
47
+ ({ x, y }) => {
48
+ const els = document.elementsFromPoint(x, y);
49
+ if (!els || els.length < 2) return null;
50
+ const topEl = els[0];
51
+ if (!topEl) return null;
52
+
53
+ // Build a brief preview of the intercepting element
54
+ const tag = topEl.tagName.toLowerCase();
55
+ const id = topEl.id ? `#${topEl.id}` : "";
56
+ const cls = topEl.className
57
+ ? `.${String(topEl.className).split(/\s+/).slice(0, 2).join(".")}`
58
+ : "";
59
+ const text = (topEl.textContent || "").trim().slice(0, 40);
60
+ return {
61
+ tag,
62
+ preview: `<${tag}${id}${cls}>${text ? ` "${text}"` : ""}`,
63
+ };
64
+ },
65
+ { x: centerX, y: centerY },
66
+ )
67
+ .catch(() => null);
68
+
69
+ if (interceptInfo) {
70
+ reasons.push(
71
+ `Element may be intercepted by ${interceptInfo.preview}`,
72
+ );
73
+ }
74
+ }
75
+ }
76
+ } catch {
77
+ // All enrichment is best-effort
78
+ }
79
+
80
+ if (reasons.length > 0) {
81
+ const enrichment = `\n[libretto diagnostics] ${reasons.join("; ")}`;
82
+ err.message = (err.message || "") + enrichment;
83
+ }
84
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ instrumentPage,
3
+ installInstrumentation,
4
+ instrumentContext,
5
+ type InstrumentationOptions,
6
+ type InstrumentedPage,
7
+ } from "./instrument.js";
8
+
9
+ export { enrichTimeoutError } from "./errors.js";
@@ -0,0 +1,406 @@
1
+ import type { Page, Locator, FrameLocator, 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
+ const instrumentedTargets = new WeakSet<object>();
49
+
50
+ // Per-page serialization queue so overlapping visualization actions don't glitch
51
+ const pageQueues = new WeakMap<Page, Promise<void>>();
52
+
53
+ function enqueue(page: Page, fn: () => Promise<void>): Promise<void> {
54
+ const prev = pageQueues.get(page) ?? Promise.resolve();
55
+ const next = prev.then(fn, fn);
56
+ pageQueues.set(page, next);
57
+ return next;
58
+ }
59
+
60
+ async function visualizeBeforeAction(
61
+ page: Page,
62
+ box: { x: number; y: number; width: number; height: number } | null,
63
+ actionName: string,
64
+ highlightMs: number,
65
+ ): Promise<void> {
66
+ if (!box) return;
67
+
68
+ // Re-ensure overlays in case DOM was replaced (e.g. page.setContent()).
69
+ await ensureGhostCursor(page);
70
+ await ensureHighlightLayer(page);
71
+
72
+ const centerX = box.x + box.width / 2;
73
+ const centerY = box.y + box.height / 2;
74
+
75
+ // Show highlight on the target element
76
+ await showHighlight(page, {
77
+ box,
78
+ durationMs: highlightMs + 200, // keep visible a bit past the cursor arrival
79
+ });
80
+
81
+ // Move ghost cursor to target
82
+ const cursorPos = await getGhostCursorPosition(page);
83
+ if (cursorPos) {
84
+ await moveGhostCursorWithDistance(page, cursorPos, {
85
+ x: centerX,
86
+ y: centerY,
87
+ });
88
+ } else {
89
+ await moveGhostCursor(page, { x: centerX, y: centerY, durationMs: 200 });
90
+ }
91
+
92
+ // For click actions, show click feedback
93
+ if (actionName === "click" || actionName === "dblclick") {
94
+ await ghostClick(page, { x: centerX, y: centerY });
95
+ }
96
+ }
97
+
98
+ async function visualizeAfterAction(page: Page): Promise<void> {
99
+ await clearHighlights(page);
100
+ }
101
+
102
+ function wrapLocatorActions(
103
+ locator: Locator,
104
+ page: Page,
105
+ opts: Required<
106
+ Pick<InstrumentationOptions, "visualize" | "highlightBeforeActionMs">
107
+ > &
108
+ InstrumentationOptions,
109
+ ): void {
110
+ for (const method of LOCATOR_ACTIONS) {
111
+ if (typeof (locator as any)[method] !== "function") continue;
112
+ const orig = (locator as any)[method].bind(locator);
113
+
114
+ (locator as any)[method] = async (...args: any[]) => {
115
+ if (opts.visualize) {
116
+ await enqueue(page, async () => {
117
+ try {
118
+ const box = await locator.boundingBox();
119
+ await visualizeBeforeAction(
120
+ page,
121
+ box,
122
+ method,
123
+ opts.highlightBeforeActionMs,
124
+ );
125
+ } catch {
126
+ // Best-effort visualization
127
+ }
128
+ });
129
+ }
130
+
131
+ try {
132
+ const result = await orig(...args);
133
+ if (opts.visualize) {
134
+ enqueue(page, () => visualizeAfterAction(page));
135
+ }
136
+ return result;
137
+ } catch (err: any) {
138
+ if (opts.visualize) {
139
+ enqueue(page, () => visualizeAfterAction(page));
140
+ }
141
+ // Enrich timeout errors for pointer actions
142
+ if (POINTER_ACTIONS.has(method) && isTimeoutError(err)) {
143
+ await enrichTimeoutError(err, locator, page);
144
+ }
145
+ throw err;
146
+ }
147
+ };
148
+ }
149
+ }
150
+
151
+ const LOCATOR_FACTORY_METHODS = [
152
+ "locator",
153
+ "getByRole",
154
+ "getByText",
155
+ "getByLabel",
156
+ "getByPlaceholder",
157
+ "getByAltText",
158
+ "getByTitle",
159
+ "getByTestId",
160
+ "filter",
161
+ "and",
162
+ "or",
163
+ "first",
164
+ "last",
165
+ "nth",
166
+ ] as const;
167
+
168
+ const FRAME_LOCATOR_FACTORY_METHODS = [
169
+ "locator",
170
+ "getByRole",
171
+ "getByText",
172
+ "getByLabel",
173
+ "getByPlaceholder",
174
+ "getByAltText",
175
+ "getByTitle",
176
+ "getByTestId",
177
+ "owner",
178
+ "first",
179
+ "last",
180
+ "nth",
181
+ ] as const;
182
+
183
+ type InstrumentationRuntimeOptions = Required<
184
+ Pick<InstrumentationOptions, "visualize" | "highlightBeforeActionMs">
185
+ > &
186
+ InstrumentationOptions;
187
+
188
+ function instrumentLocator(
189
+ locator: Locator,
190
+ page: Page,
191
+ opts: InstrumentationRuntimeOptions,
192
+ ): Locator {
193
+ const target = locator as object;
194
+ if (instrumentedTargets.has(target)) {
195
+ return locator;
196
+ }
197
+ instrumentedTargets.add(target);
198
+
199
+ wrapLocatorActions(locator, page, opts);
200
+
201
+ for (const method of LOCATOR_FACTORY_METHODS) {
202
+ if (typeof (locator as any)[method] !== "function") continue;
203
+ const orig = (locator as any)[method].bind(locator);
204
+ (locator as any)[method] = (...args: any[]) => {
205
+ const nextLocator = orig(...args);
206
+ return instrumentLocator(nextLocator, page, opts);
207
+ };
208
+ }
209
+
210
+ if (typeof (locator as any).contentFrame === "function") {
211
+ const origContentFrame = (locator as any).contentFrame.bind(locator);
212
+ (locator as any).contentFrame = (...args: any[]) => {
213
+ const frameLocator = origContentFrame(...args);
214
+ return instrumentFrameLocator(frameLocator, page, opts);
215
+ };
216
+ }
217
+
218
+ return locator;
219
+ }
220
+
221
+ function instrumentFrameLocator(
222
+ frameLocator: FrameLocator,
223
+ page: Page,
224
+ opts: InstrumentationRuntimeOptions,
225
+ ): FrameLocator {
226
+ const target = frameLocator as object;
227
+ if (instrumentedTargets.has(target)) {
228
+ return frameLocator;
229
+ }
230
+ instrumentedTargets.add(target);
231
+
232
+ for (const method of FRAME_LOCATOR_FACTORY_METHODS) {
233
+ if (typeof (frameLocator as any)[method] !== "function") continue;
234
+ const orig = (frameLocator as any)[method].bind(frameLocator);
235
+ (frameLocator as any)[method] = (...args: any[]) => {
236
+ const result = orig(...args);
237
+ if (method === "first" || method === "last" || method === "nth") {
238
+ return instrumentFrameLocator(result, page, opts);
239
+ }
240
+ return instrumentLocator(result, page, opts);
241
+ };
242
+ }
243
+
244
+ if (typeof (frameLocator as any).frameLocator === "function") {
245
+ const origFrameLocator = (frameLocator as any).frameLocator.bind(
246
+ frameLocator,
247
+ );
248
+ (frameLocator as any).frameLocator = (...args: any[]) => {
249
+ const nestedFrameLocator = origFrameLocator(...args);
250
+ return instrumentFrameLocator(nestedFrameLocator, page, opts);
251
+ };
252
+ }
253
+
254
+ return frameLocator;
255
+ }
256
+
257
+ function isTimeoutError(err: any): boolean {
258
+ if (!err || typeof err.message !== "string") return false;
259
+ return (
260
+ err.message.includes("Timeout") ||
261
+ err.message.includes("timeout") ||
262
+ err.name === "TimeoutError"
263
+ );
264
+ }
265
+
266
+ const PAGE_LOCATOR_FACTORIES = [
267
+ "locator",
268
+ "getByRole",
269
+ "getByText",
270
+ "getByLabel",
271
+ "getByPlaceholder",
272
+ "getByAltText",
273
+ "getByTitle",
274
+ "getByTestId",
275
+ ] as const;
276
+
277
+ const PAGE_FRAME_LOCATOR_FACTORIES = ["frameLocator"] as const;
278
+
279
+ /**
280
+ * In-place patching of a Page object to add visualization and error enrichment.
281
+ * Modifies the page directly (does not return a new object).
282
+ */
283
+ export async function installInstrumentation(
284
+ page: Page,
285
+ options?: InstrumentationOptions,
286
+ ): Promise<void> {
287
+ if ((page as any).__librettoInstrumented) return;
288
+ (page as any).__librettoInstrumented = true;
289
+
290
+ const visualize = options?.visualize ?? false;
291
+ const highlightBeforeActionMs = options?.highlightBeforeActionMs ?? 350;
292
+ const mergedOpts = { ...options, visualize, highlightBeforeActionMs };
293
+
294
+ // Install overlay layers if visualization is on
295
+ if (visualize) {
296
+ await ensureGhostCursor(page, options?.ghostCursor);
297
+ await ensureHighlightLayer(page, options?.highlight);
298
+ }
299
+
300
+ // Wrap page-level locator actions (page.click, page.fill, etc.)
301
+ for (const method of LOCATOR_ACTIONS) {
302
+ if (typeof (page as any)[method] !== "function") continue;
303
+ const orig = (page as any)[method].bind(page);
304
+ (page as any)[method] = async (...args: any[]) => {
305
+ // For page-level actions, the first arg is typically the selector
306
+ if (visualize && typeof args[0] === "string") {
307
+ await enqueue(page, async () => {
308
+ try {
309
+ const loc = page.locator(args[0]);
310
+ const box = await loc.boundingBox();
311
+ await visualizeBeforeAction(
312
+ page,
313
+ box,
314
+ method,
315
+ highlightBeforeActionMs,
316
+ );
317
+ } catch {
318
+ // Best-effort
319
+ }
320
+ });
321
+ }
322
+
323
+ try {
324
+ const result = await orig(...args);
325
+ if (visualize) {
326
+ enqueue(page, () => visualizeAfterAction(page));
327
+ }
328
+ return result;
329
+ } catch (err: any) {
330
+ if (visualize) {
331
+ enqueue(page, () => visualizeAfterAction(page));
332
+ }
333
+ if (
334
+ POINTER_ACTIONS.has(method) &&
335
+ isTimeoutError(err) &&
336
+ typeof args[0] === "string"
337
+ ) {
338
+ await enrichTimeoutError(err, page.locator(args[0]), page);
339
+ }
340
+ throw err;
341
+ }
342
+ };
343
+ }
344
+
345
+ // Wrap navigation actions (no visualization, just logging)
346
+ for (const method of NAV_ACTIONS) {
347
+ if (typeof (page as any)[method] !== "function") continue;
348
+ const orig = (page as any)[method].bind(page);
349
+ (page as any)[method] = async (...args: any[]) => {
350
+ options?.logger?.info(`instrumentation:${method}`, {
351
+ url: typeof args[0] === "string" ? args[0] : page.url(),
352
+ });
353
+ return orig(...args);
354
+ };
355
+ }
356
+
357
+ // Wrap locator factories to instrument returned locators
358
+ for (const factory of PAGE_LOCATOR_FACTORIES) {
359
+ if (typeof (page as any)[factory] !== "function") continue;
360
+ const origFactory = (page as any)[factory].bind(page);
361
+ (page as any)[factory] = (...factoryArgs: any[]) => {
362
+ const locator = origFactory(...factoryArgs);
363
+ return instrumentLocator(locator, page, mergedOpts);
364
+ };
365
+ }
366
+
367
+ for (const factory of PAGE_FRAME_LOCATOR_FACTORIES) {
368
+ if (typeof (page as any)[factory] !== "function") continue;
369
+ const origFactory = (page as any)[factory].bind(page);
370
+ (page as any)[factory] = (...factoryArgs: any[]) => {
371
+ const frameLocator = origFactory(...factoryArgs);
372
+ return instrumentFrameLocator(frameLocator, page, mergedOpts);
373
+ };
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Returns a new object that proxies to the page with instrumentation applied.
379
+ * The original page is not modified.
380
+ */
381
+ export async function instrumentPage(
382
+ page: Page,
383
+ options?: InstrumentationOptions,
384
+ ): Promise<InstrumentedPage> {
385
+ await installInstrumentation(page, options);
386
+ return page as InstrumentedPage;
387
+ }
388
+
389
+ /**
390
+ * Install overlays on a page and auto-install on all future pages in the context.
391
+ * Useful when connecting to an existing browser via CDP.
392
+ */
393
+ export async function instrumentContext(
394
+ context: BrowserContext,
395
+ options?: InstrumentationOptions,
396
+ ): Promise<void> {
397
+ // Instrument all existing pages
398
+ for (const page of context.pages()) {
399
+ await installInstrumentation(page, options);
400
+ }
401
+
402
+ // Auto-instrument new pages
403
+ context.on("page", async (page) => {
404
+ await installInstrumentation(page, options);
405
+ });
406
+ }
@@ -0,0 +1,81 @@
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(
52
+ (part): part is typeof part & { type: "text" } =>
53
+ part.type === "text",
54
+ )
55
+ .map((part) => ({ type: "text" as const, text: part.text })),
56
+ };
57
+ }
58
+ return {
59
+ role: "user" as const,
60
+ content: msg.content.map((part) =>
61
+ part.type === "text"
62
+ ? { type: "text" as const, text: part.text }
63
+ : {
64
+ type: "image" as const,
65
+ image: part.image,
66
+ ...(part.mediaType ? { mediaType: part.mediaType } : {}),
67
+ },
68
+ ),
69
+ };
70
+ });
71
+
72
+ const { object } = await generateObject({
73
+ model,
74
+ schema: opts.schema,
75
+ messages,
76
+ temperature: opts.temperature ?? 0,
77
+ });
78
+ return object as ZodOutput<T>;
79
+ },
80
+ };
81
+ }