libretto 0.5.1 → 0.5.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 +7 -3
- package/dist/cli/commands/init.js +4 -21
- package/dist/cli/core/ai-config.js +12 -2
- package/dist/cli/core/browser.js +75 -8
- package/dist/cli/core/session-telemetry.js +429 -172
- package/dist/cli/core/telemetry.js +10 -2
- package/dist/shared/condense-dom/condense-dom.js +11 -56
- package/dist/shared/dom-semantics.d.ts +8 -0
- package/dist/shared/dom-semantics.js +69 -0
- package/dist/shared/run/browser.js +40 -1
- package/dist/shared/visualization/ghost-cursor.js +17 -4
- package/package.json +1 -1
- package/skills/libretto/SKILL.md +52 -38
- package/skills/libretto/references/action-logs.md +101 -0
- package/skills/libretto/references/auth-profiles.md +1 -2
- package/skills/libretto/references/code-generation-rules.md +4 -2
- package/skills/libretto/references/pages-and-page-targeting.md +1 -1
- package/src/cli/commands/init.ts +5 -24
- package/src/cli/core/ai-config.ts +12 -1
- package/src/cli/core/browser.ts +82 -8
- package/src/cli/core/session-telemetry.ts +431 -190
- package/src/cli/core/telemetry.ts +23 -1
- package/src/shared/condense-dom/condense-dom.ts +12 -64
- package/src/shared/dom-semantics.ts +68 -0
- package/src/shared/run/browser.ts +53 -0
- package/src/shared/visualization/ghost-cursor.ts +22 -4
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
* 12. Whitespace — collapse (preserve <pre> content)
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import {
|
|
24
|
+
filterSemanticClasses,
|
|
25
|
+
INTERACTIVE_ROLE_NAMES,
|
|
26
|
+
INTERACTIVE_TAG_NAMES,
|
|
27
|
+
TEST_ATTRIBUTE_NAMES,
|
|
28
|
+
TRUSTED_ATTRIBUTE_NAMES,
|
|
29
|
+
} from "../dom-semantics.js";
|
|
30
|
+
|
|
23
31
|
export type CondenseDomResult = {
|
|
24
32
|
/** The condensed HTML string. Valid, parseable HTML. */
|
|
25
33
|
html: string;
|
|
@@ -37,25 +45,8 @@ type ParsedAttribute = {
|
|
|
37
45
|
value: string | null;
|
|
38
46
|
};
|
|
39
47
|
|
|
40
|
-
const TEST_ATTRS = new Set(
|
|
41
|
-
const TRUSTED_ATTRS = new Set(
|
|
42
|
-
"id",
|
|
43
|
-
"name",
|
|
44
|
-
"for",
|
|
45
|
-
"tabindex",
|
|
46
|
-
"contenteditable",
|
|
47
|
-
"role",
|
|
48
|
-
"title",
|
|
49
|
-
"alt",
|
|
50
|
-
"type",
|
|
51
|
-
"value",
|
|
52
|
-
"placeholder",
|
|
53
|
-
"autocomplete",
|
|
54
|
-
"href",
|
|
55
|
-
"action",
|
|
56
|
-
"method",
|
|
57
|
-
"src",
|
|
58
|
-
]);
|
|
48
|
+
const TEST_ATTRS: Set<string> = new Set(TEST_ATTRIBUTE_NAMES);
|
|
49
|
+
const TRUSTED_ATTRS: Set<string> = new Set(TRUSTED_ATTRIBUTE_NAMES);
|
|
59
50
|
const STATE_ATTRS = new Set([
|
|
60
51
|
"disabled",
|
|
61
52
|
"hidden",
|
|
@@ -94,28 +85,8 @@ const SCRIPT_ATTRS = new Set([
|
|
|
94
85
|
"referrerpolicy",
|
|
95
86
|
]);
|
|
96
87
|
const STYLE_TAG_ATTRS = new Set(["media", "type", "nonce", "title"]);
|
|
97
|
-
const INTERACTIVE_TAGS = new Set(
|
|
98
|
-
|
|
99
|
-
"button",
|
|
100
|
-
"input",
|
|
101
|
-
"select",
|
|
102
|
-
"textarea",
|
|
103
|
-
"form",
|
|
104
|
-
"details",
|
|
105
|
-
"dialog",
|
|
106
|
-
"label",
|
|
107
|
-
]);
|
|
108
|
-
const INTERACTIVE_ROLES = new Set([
|
|
109
|
-
"button",
|
|
110
|
-
"link",
|
|
111
|
-
"tab",
|
|
112
|
-
"menuitem",
|
|
113
|
-
"checkbox",
|
|
114
|
-
"radio",
|
|
115
|
-
"switch",
|
|
116
|
-
"slider",
|
|
117
|
-
"combobox",
|
|
118
|
-
]);
|
|
88
|
+
const INTERACTIVE_TAGS: Set<string> = new Set(INTERACTIVE_TAG_NAMES);
|
|
89
|
+
const INTERACTIVE_ROLES: Set<string> = new Set(INTERACTIVE_ROLE_NAMES);
|
|
119
90
|
const OPEN_TAG_PATTERN =
|
|
120
91
|
/<([a-zA-Z][\w:-]*)(\s(?:[^"'<>/]|"[^"]*"|'[^']*')*)?\s*(\/?)>/g;
|
|
121
92
|
|
|
@@ -458,29 +429,6 @@ function normalizeUrlValue(value: string): string {
|
|
|
458
429
|
}
|
|
459
430
|
}
|
|
460
431
|
|
|
461
|
-
function filterSemanticClasses(value: string): string {
|
|
462
|
-
const classes = value.split(/\s+/).filter(Boolean);
|
|
463
|
-
const kept = classes.filter((cls) => !isObfuscatedClass(cls));
|
|
464
|
-
return kept.join(" ");
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Heuristic: a class name is "obfuscated" if it looks like a hash or random ID
|
|
469
|
-
* rather than a human-readable semantic name.
|
|
470
|
-
*/
|
|
471
|
-
function isObfuscatedClass(cls: string): boolean {
|
|
472
|
-
if (cls.length > 80) return true;
|
|
473
|
-
if (/^_?[0-9a-f]{6,}$/i.test(cls)) return true;
|
|
474
|
-
if (/^[a-z]+_[0-9a-f]{4,}$/i.test(cls)) return true;
|
|
475
|
-
if (/^[a-z]{1,2}[0-9]{2,}$/i.test(cls)) return true;
|
|
476
|
-
|
|
477
|
-
const digits = (cls.match(/[0-9]/g) || []).length;
|
|
478
|
-
const letters = (cls.match(/[a-zA-Z]/g) || []).length;
|
|
479
|
-
if (cls.length >= 6 && digits >= letters * 0.5 && digits >= 2) return true;
|
|
480
|
-
|
|
481
|
-
return false;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
432
|
function parseAttributes(rawAttrs: string): ParsedAttribute[] {
|
|
485
433
|
const attrs: ParsedAttribute[] = [];
|
|
486
434
|
const attrPattern =
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const TEST_ATTRIBUTE_NAMES = [
|
|
2
|
+
"data-testid",
|
|
3
|
+
"data-test",
|
|
4
|
+
"data-qa",
|
|
5
|
+
"data-cy",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export const TRUSTED_ATTRIBUTE_NAMES = [
|
|
9
|
+
"id",
|
|
10
|
+
"name",
|
|
11
|
+
"for",
|
|
12
|
+
"tabindex",
|
|
13
|
+
"contenteditable",
|
|
14
|
+
"role",
|
|
15
|
+
"title",
|
|
16
|
+
"alt",
|
|
17
|
+
"type",
|
|
18
|
+
"value",
|
|
19
|
+
"placeholder",
|
|
20
|
+
"autocomplete",
|
|
21
|
+
"href",
|
|
22
|
+
"action",
|
|
23
|
+
"method",
|
|
24
|
+
"src",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export const INTERACTIVE_TAG_NAMES = [
|
|
28
|
+
"a",
|
|
29
|
+
"button",
|
|
30
|
+
"input",
|
|
31
|
+
"select",
|
|
32
|
+
"textarea",
|
|
33
|
+
"form",
|
|
34
|
+
"details",
|
|
35
|
+
"dialog",
|
|
36
|
+
"label",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export const INTERACTIVE_ROLE_NAMES = [
|
|
40
|
+
"button",
|
|
41
|
+
"link",
|
|
42
|
+
"tab",
|
|
43
|
+
"menuitem",
|
|
44
|
+
"checkbox",
|
|
45
|
+
"radio",
|
|
46
|
+
"switch",
|
|
47
|
+
"slider",
|
|
48
|
+
"combobox",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
export function filterSemanticClasses(value: string): string {
|
|
52
|
+
const classes = value.split(/\s+/).filter(Boolean);
|
|
53
|
+
const kept = classes.filter((cls) => !isObfuscatedClass(cls));
|
|
54
|
+
return kept.join(" ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isObfuscatedClass(cls: string): boolean {
|
|
58
|
+
if (cls.length > 80) return true;
|
|
59
|
+
if (/^_?[0-9a-f]{6,}$/i.test(cls)) return true;
|
|
60
|
+
if (/^[a-z]+_[0-9a-f]{4,}$/i.test(cls)) return true;
|
|
61
|
+
if (/^[a-z]{1,2}[0-9]{2,}$/i.test(cls)) return true;
|
|
62
|
+
|
|
63
|
+
const digits = (cls.match(/[0-9]/g) || []).length;
|
|
64
|
+
const letters = (cls.match(/[a-zA-Z]/g) || []).length;
|
|
65
|
+
if (cls.length >= 6 && digits >= letters * 0.5 && digits >= 2) return true;
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
SESSION_STATE_VERSION,
|
|
12
12
|
SessionStateFileSchema,
|
|
13
13
|
} from "../state/session-state.js";
|
|
14
|
+
import { readLibrettoConfig } from "../../cli/core/ai-config.js";
|
|
14
15
|
|
|
15
16
|
async function pickFreePort(): Promise<number> {
|
|
16
17
|
return await new Promise((resolve, reject) => {
|
|
@@ -44,6 +45,53 @@ export type BrowserSession = {
|
|
|
44
45
|
close: () => Promise<void>;
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
function resolveWindowPosition(): { x: number; y: number } | undefined {
|
|
49
|
+
return readLibrettoConfig().windowPosition;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function applyWindowPosition(
|
|
53
|
+
browser: Browser,
|
|
54
|
+
context: BrowserContext,
|
|
55
|
+
page: Page,
|
|
56
|
+
windowPosition: { x: number; y: number } | undefined,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
if (!windowPosition) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const requestedBounds = {
|
|
63
|
+
left: windowPosition.x,
|
|
64
|
+
top: windowPosition.y,
|
|
65
|
+
windowState: "normal" as const,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const pageCdp = await context.newCDPSession(page);
|
|
69
|
+
let browserCdp:
|
|
70
|
+
| Awaited<ReturnType<Browser["newBrowserCDPSession"]>>
|
|
71
|
+
| undefined;
|
|
72
|
+
try {
|
|
73
|
+
const targetInfo = await pageCdp.send("Target.getTargetInfo");
|
|
74
|
+
const targetId = (
|
|
75
|
+
targetInfo as { targetInfo?: { targetId?: string } }
|
|
76
|
+
).targetInfo?.targetId;
|
|
77
|
+
browserCdp = await browser.newBrowserCDPSession();
|
|
78
|
+
const windowResult = await browserCdp.send(
|
|
79
|
+
"Browser.getWindowForTarget",
|
|
80
|
+
targetId ? { targetId } : {},
|
|
81
|
+
);
|
|
82
|
+
await browserCdp.send("Browser.setWindowBounds", {
|
|
83
|
+
windowId: windowResult.windowId,
|
|
84
|
+
bounds: requestedBounds,
|
|
85
|
+
});
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
87
|
+
} catch {
|
|
88
|
+
// Best-effort: window positioning should not prevent browser launch.
|
|
89
|
+
} finally {
|
|
90
|
+
await pageCdp.detach().catch(() => {});
|
|
91
|
+
await browserCdp?.detach().catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
47
95
|
export async function launchBrowser({
|
|
48
96
|
sessionName,
|
|
49
97
|
headless = false,
|
|
@@ -51,12 +99,16 @@ export async function launchBrowser({
|
|
|
51
99
|
storageStatePath,
|
|
52
100
|
}: LaunchBrowserArgs): Promise<BrowserSession> {
|
|
53
101
|
const debugPort = await pickFreePort();
|
|
102
|
+
const windowPosition = headless ? undefined : resolveWindowPosition();
|
|
54
103
|
const browser = await chromium.launch({
|
|
55
104
|
headless,
|
|
56
105
|
args: [
|
|
57
106
|
"--disable-blink-features=AutomationControlled",
|
|
58
107
|
`--remote-debugging-port=${debugPort}`,
|
|
59
108
|
"--no-focus-on-check",
|
|
109
|
+
...(windowPosition
|
|
110
|
+
? [`--window-position=${windowPosition.x},${windowPosition.y}`]
|
|
111
|
+
: []),
|
|
60
112
|
],
|
|
61
113
|
});
|
|
62
114
|
|
|
@@ -65,6 +117,7 @@ export async function launchBrowser({
|
|
|
65
117
|
...(storageStatePath ? { storageState: storageStatePath } : {}),
|
|
66
118
|
});
|
|
67
119
|
const page = await context.newPage();
|
|
120
|
+
await applyWindowPosition(browser, context, page, windowPosition);
|
|
68
121
|
page.setDefaultTimeout(30_000);
|
|
69
122
|
page.setDefaultNavigationTimeout(45_000);
|
|
70
123
|
|
|
@@ -16,7 +16,7 @@ export type GhostCursorOptions = {
|
|
|
16
16
|
const DEFAULTS: Required<GhostCursorOptions> = {
|
|
17
17
|
style: "minimal",
|
|
18
18
|
color: "rgba(255, 70, 70, 0.9)",
|
|
19
|
-
size:
|
|
19
|
+
size: 23,
|
|
20
20
|
zIndex: 2147483646,
|
|
21
21
|
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
|
22
22
|
minDurationMs: 100,
|
|
@@ -38,20 +38,38 @@ function buildCursorSvg(
|
|
|
38
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
39
|
}
|
|
40
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">
|
|
41
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 2px 6px rgba(15,23,42,0.22));">
|
|
42
42
|
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>
|
|
43
43
|
</svg>`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function buildCursorMarkup(
|
|
47
|
+
style: GhostCursorStyle,
|
|
48
|
+
color: string,
|
|
49
|
+
size: number,
|
|
50
|
+
): string {
|
|
51
|
+
const cursor = buildCursorSvg(style, color, size);
|
|
52
|
+
const badgeHeight = Math.max(12, Math.round(size * 0.54));
|
|
53
|
+
const fontSize = Math.max(8, Math.round(size * 0.28));
|
|
54
|
+
const minWidth = Math.max(28, Math.round(size * 1.28));
|
|
55
|
+
const paddingX = Math.max(5, Math.round(size * 0.2));
|
|
56
|
+
const left = Math.round(size * 0.84);
|
|
57
|
+
const top = Math.round(size * 0.74);
|
|
58
|
+
const width = Math.round(size * 2.4);
|
|
59
|
+
const height = Math.round(size * 1.95);
|
|
60
|
+
const badge = `<div aria-hidden="true" style="position:absolute;left:${left}px;top:${top}px;display:flex;align-items:center;justify-content:center;min-width:${minWidth}px;height:${badgeHeight}px;padding:0 ${paddingX}px;border-radius:${badgeHeight}px;background:${color};color:rgba(255,255,255,0.96);font:700 ${fontSize}px/1 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:0.02em;white-space:nowrap;border:1px solid rgba(0,0,0,0.16);box-shadow:0 4px 12px rgba(0,0,0,0.14);transform-origin:left center;">Agent</div>`;
|
|
61
|
+
return `<div style="position:relative;width:${width}px;height:${height}px;overflow:visible;">${cursor}${badge}</div>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
function buildInitScript(opts: Required<GhostCursorOptions>): string {
|
|
47
|
-
const
|
|
65
|
+
const markup = buildCursorMarkup(opts.style, opts.color, opts.size);
|
|
48
66
|
return `
|
|
49
67
|
(function() {
|
|
50
68
|
if (document.getElementById("${CURSOR_ID}")) return;
|
|
51
69
|
var el = document.createElement("div");
|
|
52
70
|
el.id = "${CURSOR_ID}";
|
|
53
71
|
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(
|
|
72
|
+
el.innerHTML = ${JSON.stringify(markup)};
|
|
55
73
|
document.documentElement.appendChild(el);
|
|
56
74
|
})();
|
|
57
75
|
`;
|