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,148 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { LoggerSink } from "./logger.js";
4
+
5
+ export function createFileLogSink({
6
+ filePath,
7
+ }: {
8
+ filePath: string;
9
+ }): LoggerSink {
10
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
11
+
12
+ const writeStream = fs.createWriteStream(filePath, { flags: "a" });
13
+
14
+ return {
15
+ write: ({ id, scope, level, event, data, options }) => {
16
+ if (writeStream.destroyed || writeStream.writableEnded) {
17
+ return;
18
+ }
19
+ const timestamp = options?.timestamp || new Date();
20
+
21
+ const logEntry = {
22
+ timestamp: timestamp.toISOString(),
23
+ id,
24
+ level,
25
+ scope,
26
+ event,
27
+ data,
28
+ };
29
+
30
+ const jsonLine = JSON.stringify(logEntry) + "\n";
31
+
32
+ try {
33
+ writeStream.write(jsonLine, (error) => {
34
+ if (error) {
35
+ console.error("Failed to write to log file:", error);
36
+ console[level]({ id, scope, event, data, timestamp });
37
+ }
38
+ });
39
+ } catch (error) {
40
+ console.error("Failed to write to log file:", error);
41
+ console[level]({ id, scope, event, data, timestamp });
42
+ }
43
+ },
44
+ flush: () =>
45
+ new Promise<void>((resolve, reject) => {
46
+ if (
47
+ !writeStream.writable ||
48
+ writeStream.writableEnded ||
49
+ writeStream.destroyed
50
+ ) {
51
+ resolve();
52
+ return;
53
+ }
54
+ writeStream.write("", (error) => {
55
+ if (error) {
56
+ reject(error);
57
+ } else {
58
+ resolve();
59
+ }
60
+ });
61
+ }),
62
+ close: () =>
63
+ new Promise<void>((resolve) => {
64
+ if (writeStream.destroyed || writeStream.closed) {
65
+ resolve();
66
+ return;
67
+ }
68
+ let settled = false;
69
+ const done = () => {
70
+ if (settled) return;
71
+ settled = true;
72
+ resolve();
73
+ };
74
+ writeStream.once("finish", done);
75
+ writeStream.once("close", done);
76
+ writeStream.once("error", done);
77
+ try {
78
+ writeStream.end();
79
+ } catch {
80
+ done();
81
+ }
82
+ }),
83
+ };
84
+ }
85
+
86
+ // ANSI color codes
87
+ const colors = {
88
+ reset: "\x1b[0m",
89
+ gray: "\x1b[90m",
90
+ red: "\x1b[31m",
91
+ yellow: "\x1b[33m",
92
+ blue: "\x1b[34m",
93
+ cyan: "\x1b[36m",
94
+ };
95
+
96
+ function formatTimestamp(date: Date): string {
97
+ return date.toISOString().replace("T", " ").replace("Z", "");
98
+ }
99
+
100
+ export const prettyConsoleSink: LoggerSink = {
101
+ write: ({ scope, level, event, data, options }) => {
102
+ const timestamp = `${colors.gray}${formatTimestamp(options?.timestamp || new Date())}${colors.reset}`;
103
+ const levelColor =
104
+ level === "error"
105
+ ? colors.red
106
+ : level === "warn"
107
+ ? colors.yellow
108
+ : colors.blue;
109
+ const coloredScope = scope ? `${colors.cyan}[${scope}]${colors.reset}` : "";
110
+
111
+ const logPrefix = `${timestamp} ${levelColor}${level.toUpperCase()}${colors.reset} ${coloredScope} ${event}`;
112
+
113
+ if (level === "error" && data.error) {
114
+ const { error, ...otherData } = data;
115
+ console.error(logPrefix);
116
+ if (error.stack) {
117
+ console.error(` ${error.stack}`);
118
+ } else if (error.type && error.message) {
119
+ console.error(` ${error.type}: ${error.message}`);
120
+ }
121
+ if (Object.keys(otherData).length > 0) {
122
+ console.error(JSON.stringify(otherData, null, 2));
123
+ }
124
+ } else {
125
+ console[level](logPrefix);
126
+ if (Object.keys(data).length > 0) {
127
+ console[level](JSON.stringify(data, null, 2));
128
+ }
129
+ }
130
+ },
131
+ };
132
+
133
+ export const jsonlConsoleSink: LoggerSink = {
134
+ write: ({ id, scope, level, event, data, options }) => {
135
+ const timestamp = options?.timestamp || new Date();
136
+
137
+ const logEntry = {
138
+ timestamp: timestamp.toISOString(),
139
+ id,
140
+ level,
141
+ scope: scope || undefined,
142
+ event,
143
+ data,
144
+ };
145
+
146
+ console.log(JSON.stringify(logEntry));
147
+ },
148
+ };
@@ -0,0 +1,110 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolveLibrettoRepoRoot } from "./repo-root.js";
4
+
5
+ const LIBRETTO_DIRNAME = ".libretto";
6
+ const LIBRETTO_SESSIONS_DIRNAME = "sessions";
7
+ const SESSION_STATE_FILENAME = "state.json";
8
+ const RUNNER_LOG_DIRNAME = "logs";
9
+ const RUNNER_LOG_FILENAME = "logs.jsonl";
10
+ const PAUSED_SIGNAL_SUFFIX = "paused";
11
+ const RESUME_SIGNAL_SUFFIX = "resume";
12
+
13
+ function getLibrettoRoot(cwd: string = process.cwd()): string {
14
+ return join(resolveLibrettoRepoRoot(cwd), LIBRETTO_DIRNAME);
15
+ }
16
+
17
+ function getLibrettoSessionsDir(cwd: string = process.cwd()): string {
18
+ return join(getLibrettoRoot(cwd), LIBRETTO_SESSIONS_DIRNAME);
19
+ }
20
+
21
+ function getLibrettoSessionDir(
22
+ sessionName: string,
23
+ cwd: string = process.cwd(),
24
+ ): string {
25
+ return join(getLibrettoSessionsDir(cwd), sessionName);
26
+ }
27
+
28
+ function getLibrettoSessionStatePath(
29
+ sessionName: string,
30
+ cwd: string = process.cwd(),
31
+ ): string {
32
+ return join(getLibrettoSessionDir(sessionName, cwd), SESSION_STATE_FILENAME);
33
+ }
34
+
35
+ export function getLibrettoPauseSignalDir(
36
+ sessionName: string,
37
+ cwd: string = process.cwd(),
38
+ ): string {
39
+ return getLibrettoSessionDir(sessionName, cwd);
40
+ }
41
+
42
+ function getLibrettoRunnerLogDir(
43
+ sessionName: string,
44
+ cwd: string = process.cwd(),
45
+ ): string {
46
+ return join(getLibrettoSessionDir(sessionName, cwd), RUNNER_LOG_DIRNAME);
47
+ }
48
+
49
+ export function getRunnerLogPathForDir(logDir: string): string {
50
+ return join(logDir, RUNNER_LOG_FILENAME);
51
+ }
52
+
53
+ export function getPauseSignalPathForDir(
54
+ signalDir: string,
55
+ sessionName: string,
56
+ signal: "paused" | "resume",
57
+ ): string {
58
+ const suffix =
59
+ signal === "paused" ? PAUSED_SIGNAL_SUFFIX : RESUME_SIGNAL_SUFFIX;
60
+ return join(signalDir, `${sessionName}.${suffix}`);
61
+ }
62
+
63
+ export function getLibrettoPausedSignalPath(
64
+ sessionName: string,
65
+ cwd: string = process.cwd(),
66
+ ): string {
67
+ return getPauseSignalPathForDir(
68
+ getLibrettoPauseSignalDir(sessionName, cwd),
69
+ sessionName,
70
+ "paused",
71
+ );
72
+ }
73
+
74
+ export function getLibrettoResumeSignalPath(
75
+ sessionName: string,
76
+ cwd: string = process.cwd(),
77
+ ): string {
78
+ return getPauseSignalPathForDir(
79
+ getLibrettoPauseSignalDir(sessionName, cwd),
80
+ sessionName,
81
+ "resume",
82
+ );
83
+ }
84
+
85
+ export function ensureLibrettoSessionStatePath(
86
+ sessionName: string,
87
+ cwd: string = process.cwd(),
88
+ ): string {
89
+ const filePath = getLibrettoSessionStatePath(sessionName, cwd);
90
+ mkdirSync(dirname(filePath), { recursive: true });
91
+ return filePath;
92
+ }
93
+
94
+ export function ensureLibrettoPauseSignalDir(
95
+ sessionName: string,
96
+ cwd: string = process.cwd(),
97
+ ): string {
98
+ const dir = getLibrettoPauseSignalDir(sessionName, cwd);
99
+ mkdirSync(dir, { recursive: true });
100
+ return dir;
101
+ }
102
+
103
+ export function ensureLibrettoRunnerLogDir(
104
+ sessionName: string,
105
+ cwd: string = process.cwd(),
106
+ ): string {
107
+ const dir = getLibrettoRunnerLogDir(sessionName, cwd);
108
+ mkdirSync(dir, { recursive: true });
109
+ return dir;
110
+ }
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+
4
+ const repoRootCache = new Map<string, string>();
5
+
6
+ export function resolveLibrettoRepoRoot(cwd: string = process.cwd()): string {
7
+ const override = process.env.LIBRETTO_REPO_ROOT?.trim();
8
+ if (override) {
9
+ return resolve(override);
10
+ }
11
+
12
+ const normalizedCwd = resolve(cwd);
13
+ const cached = repoRootCache.get(normalizedCwd);
14
+ if (cached) {
15
+ return cached;
16
+ }
17
+
18
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
19
+ cwd: normalizedCwd,
20
+ encoding: "utf-8",
21
+ });
22
+
23
+ const repoRoot =
24
+ result.status === 0 && result.stdout ? result.stdout.trim() : normalizedCwd;
25
+ repoRootCache.set(normalizedCwd, repoRoot);
26
+ return repoRoot;
27
+ }
@@ -0,0 +1,6 @@
1
+ // --- Browser ---
2
+ export {
3
+ launchBrowser,
4
+ type LaunchBrowserArgs,
5
+ type BrowserSession,
6
+ } from "./browser.js";
@@ -0,0 +1,107 @@
1
+ import {
2
+ chromium,
3
+ type Browser,
4
+ type BrowserContext,
5
+ type Page,
6
+ } from "playwright";
7
+ import { createServer } from "node:net";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { ensureLibrettoSessionStatePath } from "../paths/paths.js";
10
+ import {
11
+ SESSION_STATE_VERSION,
12
+ SessionStateFileSchema,
13
+ } from "../state/session-state.js";
14
+
15
+ async function pickFreePort(): Promise<number> {
16
+ return await new Promise((resolve, reject) => {
17
+ const server = createServer();
18
+ server.unref();
19
+ server.on("error", reject);
20
+ server.listen(0, "127.0.0.1", () => {
21
+ const addr = server.address();
22
+ if (addr && typeof addr === "object") {
23
+ server.close(() => resolve(addr.port));
24
+ return;
25
+ }
26
+ server.close(() => reject(new Error("Failed to resolve debug port")));
27
+ });
28
+ });
29
+ }
30
+
31
+ export type LaunchBrowserArgs = {
32
+ sessionName: string;
33
+ headless?: boolean;
34
+ viewport?: { width: number; height: number };
35
+ storageStatePath?: string;
36
+ };
37
+
38
+ export type BrowserSession = {
39
+ browser: Browser;
40
+ context: BrowserContext;
41
+ page: Page;
42
+ debugPort: number;
43
+ metadataPath: string;
44
+ close: () => Promise<void>;
45
+ };
46
+
47
+ export async function launchBrowser({
48
+ sessionName,
49
+ headless = false,
50
+ viewport = { width: 1366, height: 768 },
51
+ storageStatePath,
52
+ }: LaunchBrowserArgs): Promise<BrowserSession> {
53
+ const debugPort = await pickFreePort();
54
+ const browser = await chromium.launch({
55
+ headless,
56
+ args: [
57
+ "--disable-blink-features=AutomationControlled",
58
+ `--remote-debugging-port=${debugPort}`,
59
+ "--no-focus-on-check",
60
+ ],
61
+ });
62
+
63
+ const context = await browser.newContext({
64
+ viewport,
65
+ ...(storageStatePath ? { storageState: storageStatePath } : {}),
66
+ });
67
+ const page = await context.newPage();
68
+ page.setDefaultTimeout(30_000);
69
+ page.setDefaultNavigationTimeout(45_000);
70
+
71
+ const metadataPath = ensureLibrettoSessionStatePath(sessionName);
72
+ const existingStateRaw = existsSync(metadataPath)
73
+ ? (JSON.parse(readFileSync(metadataPath, "utf-8")) as unknown)
74
+ : undefined;
75
+
76
+ const parsedExistingState =
77
+ SessionStateFileSchema.safeParse(existingStateRaw);
78
+
79
+ writeFileSync(
80
+ metadataPath,
81
+ JSON.stringify(
82
+ {
83
+ version: parsedExistingState.success
84
+ ? parsedExistingState.data.version
85
+ : SESSION_STATE_VERSION,
86
+ session: sessionName,
87
+ port: debugPort,
88
+ pid: process.pid,
89
+ startedAt: new Date().toISOString(),
90
+ status: "active",
91
+ },
92
+ null,
93
+ 2,
94
+ ),
95
+ );
96
+
97
+ return {
98
+ browser,
99
+ context,
100
+ page,
101
+ debugPort,
102
+ metadataPath,
103
+ close: async () => {
104
+ await browser.close();
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,11 @@
1
+ export {
2
+ SESSION_STATE_VERSION,
3
+ SessionStatusSchema,
4
+ SessionStateFileSchema,
5
+ parseSessionStateData,
6
+ parseSessionStateContent,
7
+ serializeSessionState,
8
+ type SessionStatus,
9
+ type SessionState,
10
+ type SessionStateFile,
11
+ } from "./session-state.js";
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+
3
+ export const SESSION_STATE_VERSION = 1;
4
+
5
+ export const SessionStatusSchema = z.enum([
6
+ "active",
7
+ "paused",
8
+ "completed",
9
+ "failed",
10
+ "exited",
11
+ ]);
12
+ export const SessionViewportSchema = z.object({
13
+ width: z.number().int().min(1),
14
+ height: z.number().int().min(1),
15
+ });
16
+
17
+ export const SessionStateFileSchema = z.object({
18
+ version: z.literal(SESSION_STATE_VERSION),
19
+ port: z.number().int().min(0).max(65535),
20
+ pid: z.number().int().optional(),
21
+ cdpEndpoint: z.string().url().optional(),
22
+ session: z.string().min(1),
23
+ startedAt: z.string().datetime({ offset: true }),
24
+ status: SessionStatusSchema.optional(),
25
+ viewport: SessionViewportSchema.optional(),
26
+ });
27
+
28
+ export type SessionStatus = z.infer<typeof SessionStatusSchema>;
29
+ export type SessionStateFile = z.infer<typeof SessionStateFileSchema>;
30
+ export type SessionState = Omit<SessionStateFile, "version">;
31
+
32
+ function formatIssues(error: z.ZodError): string {
33
+ return error.issues
34
+ .map((issue) => {
35
+ const path = issue.path.join(".") || "root";
36
+ return `${path}: ${issue.message}`;
37
+ })
38
+ .join("; ");
39
+ }
40
+
41
+ export function parseSessionStateData(
42
+ rawState: unknown,
43
+ source: string,
44
+ ): SessionState {
45
+ const parsed = SessionStateFileSchema.safeParse(rawState);
46
+ if (!parsed.success) {
47
+ throw new Error(
48
+ `Session state at ${source} is invalid: ${formatIssues(parsed.error)}`,
49
+ );
50
+ }
51
+
52
+ const { version: _version, ...state } = parsed.data;
53
+ return state;
54
+ }
55
+
56
+ export function parseSessionStateContent(
57
+ content: string,
58
+ source: string,
59
+ ): SessionState {
60
+ let rawState: unknown;
61
+ try {
62
+ rawState = JSON.parse(content);
63
+ } catch (error) {
64
+ throw new Error(
65
+ `Session state at ${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
66
+ );
67
+ }
68
+
69
+ return parseSessionStateData(rawState, source);
70
+ }
71
+
72
+ export function serializeSessionState(state: SessionState): SessionStateFile {
73
+ return SessionStateFileSchema.parse({
74
+ version: SESSION_STATE_VERSION,
75
+ ...state,
76
+ });
77
+ }
@@ -0,0 +1,213 @@
1
+ import type { Page } from "playwright";
2
+
3
+ export type GhostCursorStyle = "minimal" | "dot" | "screenstudio";
4
+
5
+ export type GhostCursorOptions = {
6
+ style?: GhostCursorStyle;
7
+ color?: string;
8
+ size?: number;
9
+ zIndex?: number;
10
+ easing?: string;
11
+ minDurationMs?: number;
12
+ maxDurationMs?: number;
13
+ speedPxPerMs?: number;
14
+ };
15
+
16
+ const DEFAULTS: Required<GhostCursorOptions> = {
17
+ style: "minimal",
18
+ color: "rgba(255, 70, 70, 0.9)",
19
+ size: 20,
20
+ zIndex: 2147483646,
21
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
22
+ minDurationMs: 100,
23
+ maxDurationMs: 600,
24
+ speedPxPerMs: 1.5,
25
+ };
26
+
27
+ const CURSOR_ID = "__libretto_ghost_cursor__";
28
+
29
+ function buildCursorSvg(
30
+ style: GhostCursorStyle,
31
+ color: string,
32
+ size: number,
33
+ ): string {
34
+ if (style === "dot") {
35
+ return `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};"></div>`;
36
+ }
37
+ if (style === "screenstudio") {
38
+ return `<div style="width:${size * 1.4}px;height:${size * 1.4}px;border-radius:50%;background:${color};box-shadow:0 0 ${size * 0.6}px ${color};opacity:0.7;"></div>`;
39
+ }
40
+ // minimal: default arrow-like SVG cursor
41
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
42
+ <path d="M5 3L19 12L12 13L9 20L5 3Z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>
43
+ </svg>`;
44
+ }
45
+
46
+ function buildInitScript(opts: Required<GhostCursorOptions>): string {
47
+ const svg = buildCursorSvg(opts.style, opts.color, opts.size);
48
+ return `
49
+ (function() {
50
+ if (document.getElementById("${CURSOR_ID}")) return;
51
+ var el = document.createElement("div");
52
+ el.id = "${CURSOR_ID}";
53
+ el.style.cssText = "position:fixed;top:0;left:0;z-index:${opts.zIndex};pointer-events:none;transform:translate3d(-100px,-100px,0);transition:none;will-change:transform,opacity;opacity:0;";
54
+ el.innerHTML = ${JSON.stringify(svg)};
55
+ document.documentElement.appendChild(el);
56
+ })();
57
+ `;
58
+ }
59
+
60
+ const installedPages = new WeakSet<Page>();
61
+
62
+ export async function ensureGhostCursor(
63
+ page: Page,
64
+ options?: GhostCursorOptions,
65
+ ): Promise<void> {
66
+ const existingOpts = (page as any).__librettoGhostCursorOpts as
67
+ | Required<GhostCursorOptions>
68
+ | undefined;
69
+ const opts = { ...DEFAULTS, ...(existingOpts ?? {}), ...options };
70
+ const initScript = buildInitScript(opts);
71
+
72
+ if (!installedPages.has(page)) {
73
+ installedPages.add(page);
74
+ await page.addInitScript({ content: initScript });
75
+ }
76
+
77
+ // Store options on the page for later use by move/click
78
+ (page as any).__librettoGhostCursorOpts = opts;
79
+
80
+ // Re-run in-page installer so cursor recovers after page.setContent() or DOM resets.
81
+ try {
82
+ await page.evaluate(new Function(initScript) as () => void);
83
+ } catch {
84
+ // Page might not be ready yet; addInitScript will handle on next navigation
85
+ }
86
+ }
87
+
88
+ export async function moveGhostCursor(
89
+ page: Page,
90
+ target: { x: number; y: number; durationMs?: number },
91
+ ): Promise<void> {
92
+ const opts: Required<GhostCursorOptions> =
93
+ (page as any).__librettoGhostCursorOpts ?? DEFAULTS;
94
+
95
+ const durationMs =
96
+ target.durationMs ??
97
+ Math.min(
98
+ opts.maxDurationMs,
99
+ Math.max(opts.minDurationMs, 200), // default ~200ms if no distance info
100
+ );
101
+
102
+ try {
103
+ await page.evaluate(
104
+ ({ id, x, y, duration, easing }) => {
105
+ const el = document.getElementById(id);
106
+ if (!el) return;
107
+ el.style.opacity = "1";
108
+ el.style.transition = `transform ${duration}ms ${easing}`;
109
+ el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
110
+ },
111
+ {
112
+ id: CURSOR_ID,
113
+ x: target.x,
114
+ y: target.y,
115
+ duration: durationMs,
116
+ easing: opts.easing,
117
+ },
118
+ );
119
+
120
+ await page.waitForTimeout(durationMs);
121
+ } catch {
122
+ // Best-effort: page may have navigated
123
+ }
124
+ }
125
+
126
+ export async function moveGhostCursorWithDistance(
127
+ page: Page,
128
+ from: { x: number; y: number },
129
+ to: { x: number; y: number },
130
+ ): Promise<void> {
131
+ const opts: Required<GhostCursorOptions> =
132
+ (page as any).__librettoGhostCursorOpts ?? DEFAULTS;
133
+
134
+ const dx = to.x - from.x;
135
+ const dy = to.y - from.y;
136
+ const distance = Math.sqrt(dx * dx + dy * dy);
137
+ const durationMs = Math.min(
138
+ opts.maxDurationMs,
139
+ Math.max(opts.minDurationMs, distance / opts.speedPxPerMs),
140
+ );
141
+
142
+ await moveGhostCursor(page, { x: to.x, y: to.y, durationMs });
143
+ }
144
+
145
+ export async function ghostClick(
146
+ page: Page,
147
+ target: { x: number; y: number },
148
+ ): Promise<void> {
149
+ try {
150
+ // Click feedback: scale down on "press"
151
+ await page.evaluate(
152
+ ({ id, x, y }) => {
153
+ const el = document.getElementById(id);
154
+ if (!el) return;
155
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(0.93)`;
156
+ el.style.transition = "transform 80ms ease-out";
157
+ },
158
+ { id: CURSOR_ID, x: target.x, y: target.y },
159
+ );
160
+ await page.waitForTimeout(100);
161
+
162
+ // Release: scale back up
163
+ await page.evaluate(
164
+ ({ id, x, y }) => {
165
+ const el = document.getElementById(id);
166
+ if (!el) return;
167
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(1)`;
168
+ el.style.transition = "transform 120ms ease-out";
169
+ },
170
+ { id: CURSOR_ID, x: target.x, y: target.y },
171
+ );
172
+ await page.waitForTimeout(130);
173
+ } catch {
174
+ // Best-effort
175
+ }
176
+ }
177
+
178
+ export async function hideGhostCursor(page: Page): Promise<void> {
179
+ try {
180
+ await page.evaluate(
181
+ ({ id }) => {
182
+ const el = document.getElementById(id);
183
+ if (!el) return;
184
+ el.style.transition = "opacity 300ms ease-out";
185
+ el.style.opacity = "0";
186
+ },
187
+ { id: CURSOR_ID },
188
+ );
189
+ } catch {
190
+ // Best-effort
191
+ }
192
+ }
193
+
194
+ export async function getGhostCursorPosition(
195
+ page: Page,
196
+ ): Promise<{ x: number; y: number } | null> {
197
+ try {
198
+ return await page.evaluate(
199
+ ({ id }) => {
200
+ const el = document.getElementById(id);
201
+ if (!el) return null;
202
+ const match = el.style.transform.match(
203
+ /translate3d\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/,
204
+ );
205
+ if (!match) return null;
206
+ return { x: parseFloat(match[1]!), y: parseFloat(match[2]!) };
207
+ },
208
+ { id: CURSOR_ID },
209
+ );
210
+ } catch {
211
+ return null;
212
+ }
213
+ }