feat: embed daemon JS in binary, eliminate npm install of camoufox-browser

The daemon JS files (daemon.js, server.js, browser.js, protocol.js,
snapshot.js, ipc.js) are now EMBEDDED in the compiled Rust binary via
include_dir! and extracted to ~/.gyandoot/daemon/ on first run.

This eliminates the need to npm install -g camoufox-browser@0.1.0,
which bundles an incompatible camoufox-js@0.9.3 that caused:
- 'Cannot open database' errors
- Re-downloading 298MB browser on every navigation
- Circular install/remove/install loops

Now the daemon imports camoufox-js from the global npm install (v0.11.0)
which is installed by install.sh. Clean, no bundled deps, no conflicts.

Also removed: is_npm_package_installed(), install_npm_package(),
find_daemon_js() — all replaced by gyandoot_browser::daemon::extract_daemon()
This commit is contained in:
2026-06-13 23:22:15 +05:30
parent 57308e7805
commit d5cab65099
12 changed files with 2063 additions and 197 deletions

View File

@@ -0,0 +1,383 @@
import * as os from "node:os";
import { getEnhancedSnapshot, parseRef } from "./snapshot.js";
function expandTilde(p) {
if (!p.startsWith("~/"))
return p;
return `${os.homedir()}/${p.slice(2)}`;
}
export class BrowserManager {
browser = null;
context = null;
pages = [];
activePageIndex = 0;
activeFrame = null;
isPersistentContext = false;
launchConfig = null;
dialogHandler = null;
dialogHandlerPage = null;
consoleMessages = [];
pageErrors = [];
refMap = {};
lastSnapshot = "";
isLaunched() {
return this.browser !== null || this.isPersistentContext;
}
hasPages() {
return this.pages.length > 0;
}
async ensurePage() {
if (!this.context)
throw new Error("Browser not launched. Call open first.");
if (this.pages.length > 0)
return;
const page = this.context.pages()[0] || (await this.context.newPage());
this.trackPage(page);
this.activePageIndex = this.pages.indexOf(page);
if (this.activePageIndex < 0)
this.activePageIndex = 0;
this.activeFrame = null;
await page.bringToFront().catch(() => { });
}
getPage() {
const page = this.pages[this.activePageIndex] || this.pages[0];
if (!page) {
throw new Error("Browser not launched. Call open first.");
}
return page;
}
getFrame() {
if (this.activeFrame)
return this.activeFrame;
return this.getPage().mainFrame();
}
getContext() {
if (!this.context) {
throw new Error("Browser not launched. Call open first.");
}
return this.context;
}
getActiveIndex() {
return this.activePageIndex;
}
getConsoleMessages() {
return this.consoleMessages;
}
clearConsoleMessages() {
this.consoleMessages = [];
}
getPageErrors() {
return this.pageErrors;
}
clearPageErrors() {
this.pageErrors = [];
}
getLastSnapshot() {
return this.lastSnapshot;
}
getRefMap() {
return this.refMap;
}
getLaunchConfig() {
return this.launchConfig;
}
trackPage(page) {
if (this.pages.includes(page))
return;
this.pages.push(page);
page.on("console", (msg) => {
this.consoleMessages.push({
type: msg.type(),
text: msg.text(),
timestamp: Date.now()
});
});
page.on("pageerror", (error) => {
this.pageErrors.push({ message: error.message, timestamp: Date.now() });
});
page.on("close", () => {
const idx = this.pages.indexOf(page);
if (idx < 0)
return;
if (idx === this.activePageIndex) {
this.activeFrame = null;
}
this.pages.splice(idx, 1);
if (this.pages.length === 0) {
this.activePageIndex = 0;
return;
}
if (this.activePageIndex >= this.pages.length) {
this.activePageIndex = this.pages.length - 1;
}
else if (this.activePageIndex > idx) {
this.activePageIndex--;
}
});
}
setupContextTracking(context) {
context.on("page", (page) => {
this.trackPage(page);
const idx = this.pages.indexOf(page);
if (idx >= 0 && idx !== this.activePageIndex) {
this.activePageIndex = idx;
this.activeFrame = null;
}
});
}
async launch(config) {
if (this.isLaunched())
return;
this.launchConfig = config;
let Camoufox;
try {
({ Camoufox } = await import("camoufox-js"));
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("better-sqlite3") && message.includes("Could not locate the bindings file")) {
throw new Error([
"better-sqlite3 native bindings are missing (required by camoufox-js).",
"If you installed dependencies with pnpm, allow build scripts and rebuild:",
" pnpm -C camoufox-browser install",
" pnpm -C camoufox-browser rebuild better-sqlite3",
"If you're using npm, reinstall should run the build scripts automatically."
].join("\n"));
}
throw err;
}
const headless = config.headless;
const executable_path = config.executablePath;
const args = config.args;
const proxy = config.proxy;
if (config.profile) {
const user_data_dir = expandTilde(config.profile);
const ctx = (await Camoufox({
headless,
user_data_dir,
...(executable_path ? { executable_path } : {}),
...(args ? { args } : {}),
...(proxy ? { proxy } : {}),
...(config.userAgent ? { userAgent: config.userAgent } : {}),
...(config.headers ? { extraHTTPHeaders: config.headers } : {}),
...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {})
}));
this.context = ctx;
this.isPersistentContext = true;
this.setupContextTracking(ctx);
const initial = ctx.pages();
if (initial.length > 0) {
for (const p of initial)
this.trackPage(p);
this.activePageIndex = 0;
}
else {
const p = await ctx.newPage();
this.trackPage(p);
this.activePageIndex = this.pages.indexOf(p);
}
await this.getPage().bringToFront().catch(() => { });
return;
}
const b = (await Camoufox({
headless,
...(executable_path ? { executable_path } : {}),
...(args ? { args } : {}),
...(proxy ? { proxy } : {})
}));
this.browser = b;
this.context = await b.newContext({
...(config.userAgent ? { userAgent: config.userAgent } : {}),
...(config.headers ? { extraHTTPHeaders: config.headers } : {}),
...(config.storageState ? { storageState: config.storageState } : {}),
...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {})
});
this.setupContextTracking(this.context);
const initial = this.context.pages();
if (initial.length > 0) {
for (const p of initial)
this.trackPage(p);
this.activePageIndex = 0;
}
else {
const p = await this.context.newPage();
this.trackPage(p);
this.activePageIndex = this.pages.indexOf(p);
}
await this.getPage().bringToFront().catch(() => { });
}
async navigate(url, options) {
await this.ensurePage();
const page = this.getPage();
if (options?.headers && Object.keys(options.headers).length > 0) {
await page.setExtraHTTPHeaders(options.headers);
}
await page.goto(url, { waitUntil: options?.waitUntil ?? "load" });
return { url: page.url(), title: await page.title() };
}
async newTab() {
const ctx = this.getContext();
const page = await ctx.newPage();
this.trackPage(page);
this.activePageIndex = this.pages.indexOf(page);
if (this.activePageIndex < 0)
this.activePageIndex = Math.max(0, this.pages.length - 1);
this.activeFrame = null;
await page.bringToFront().catch(() => { });
return page;
}
async newTabManaged() {
await this.newTab();
return { index: this.activePageIndex, total: this.pages.length };
}
async switchTo(index) {
if (index < 0 || index >= this.pages.length) {
throw new Error(`Invalid tab index: ${index}. Available: 0-${Math.max(0, this.pages.length - 1)}`);
}
this.activePageIndex = index;
this.activeFrame = null;
const page = this.getPage();
await page.bringToFront().catch(() => { });
return { index: this.activePageIndex, url: page.url(), title: "" };
}
async closeTab(index) {
const targetIndex = index ?? this.activePageIndex;
if (targetIndex < 0 || targetIndex >= this.pages.length) {
throw new Error(`Invalid tab index: ${targetIndex}`);
}
if (this.pages.length === 1) {
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
}
const page = this.pages[targetIndex];
await page.close();
const idx = this.pages.indexOf(page);
if (idx >= 0) {
this.pages.splice(idx, 1);
}
if (this.activePageIndex >= this.pages.length) {
this.activePageIndex = this.pages.length - 1;
}
else if (this.activePageIndex > targetIndex) {
this.activePageIndex--;
}
await this.getPage().bringToFront().catch(() => { });
return { closed: targetIndex, remaining: this.pages.length };
}
async listTabs() {
return await Promise.all(this.pages.map(async (page, index) => ({
index,
url: page.url(),
title: await page.title().catch(() => ""),
active: index === this.activePageIndex
})));
}
async getSnapshot(options) {
const frame = this.getFrame();
const snapshot = await getEnhancedSnapshot(frame, options);
this.refMap = snapshot.refs;
this.lastSnapshot = snapshot.tree;
return snapshot;
}
getLocatorFromRef(refArg) {
const ref = parseRef(refArg);
if (!ref)
return null;
const refData = this.refMap[ref];
if (!refData)
return null;
const frame = this.getFrame();
if (refData.role === "clickable" || refData.role === "focusable") {
return frame.locator(refData.selector);
}
let locator = frame.getByRole(refData.role, {
name: refData.name,
exact: true
});
if (refData.nth !== undefined) {
locator = locator.nth(refData.nth);
}
return locator;
}
getLocator(selectorOrRef) {
const refLocator = this.getLocatorFromRef(selectorOrRef);
if (refLocator)
return refLocator;
return this.getFrame().locator(selectorOrRef);
}
switchToMainFrame() {
this.activeFrame = null;
}
async switchToFrame(options) {
const page = this.getPage();
if (options.selector) {
const frameElement = await page.$(options.selector);
if (!frameElement) {
throw new Error(`Frame not found: ${options.selector}`);
}
const frame = await frameElement.contentFrame();
if (!frame) {
throw new Error(`Element is not a frame: ${options.selector}`);
}
this.activeFrame = frame;
return;
}
if (options.name) {
const frame = page.frame({ name: options.name });
if (!frame)
throw new Error(`Frame not found with name: ${options.name}`);
this.activeFrame = frame;
return;
}
if (options.url) {
const frame = page.frame({ url: options.url });
if (!frame)
throw new Error(`Frame not found with URL: ${options.url}`);
this.activeFrame = frame;
return;
}
throw new Error("Missing frame selector");
}
setDialogHandler(response, promptText) {
const page = this.getPage();
if (this.dialogHandler && this.dialogHandlerPage) {
this.dialogHandlerPage.removeListener("dialog", this.dialogHandler);
}
this.dialogHandler = async (dialog) => {
if (response === "accept") {
await dialog.accept(promptText);
}
else {
await dialog.dismiss();
}
};
this.dialogHandlerPage = page;
page.on("dialog", this.dialogHandler);
}
async close() {
const browser = this.browser;
const context = this.context;
this.browser = null;
this.context = null;
this.pages = [];
this.activePageIndex = 0;
this.activeFrame = null;
this.isPersistentContext = false;
this.launchConfig = null;
this.dialogHandler = null;
this.dialogHandlerPage = null;
this.refMap = {};
this.lastSnapshot = "";
if (browser) {
try {
await browser.close();
}
catch { }
return;
}
if (context) {
try {
await context.close();
}
catch { }
}
}
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
/**
* Daemon entrypoint (spawned by CLI).
*
* Listens on a per-session IPC endpoint (Unix socket or Windows localhost TCP).
* The CLI communicates via JSON-line protocol.
*/
import { DaemonServer } from "./server.js";
async function main() {
const server = new DaemonServer();
await server.start();
}
main().catch((err) => {
process.stderr.write(`[camoufox-browser] Fatal: ${err?.message ?? String(err)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,76 @@
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
export const isWindows = process.platform === "win32";
export function isValidSessionName(name) {
if (!name)
return false;
if (name.length > 64)
return false;
if (name.includes(".."))
return false;
return /^[a-zA-Z0-9._-]+$/.test(name);
}
/**
* Get the base directory for socket/pid files.
* Priority: CAMOUFOX_BROWSER_SOCKET_DIR > AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.camoufox-browser > tmpdir
*/
export function getSocketDir() {
const explicit = process.env.CAMOUFOX_BROWSER_SOCKET_DIR || process.env.AGENT_BROWSER_SOCKET_DIR;
if (explicit && explicit.length > 0)
return explicit;
if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) {
return path.join(process.env.XDG_RUNTIME_DIR, "camoufox-browser");
}
const home = os.homedir();
if (home)
return path.join(home, ".camoufox-browser");
return path.join(os.tmpdir(), "camoufox-browser");
}
export function ensureSocketDir() {
const dir = getSocketDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
export function getPidFile(session) {
return path.join(getSocketDir(), `${session}.pid`);
}
export function getUnixSocketPath(session) {
return path.join(getSocketDir(), `${session}.sock`);
}
export function getPortFile(session) {
return path.join(getSocketDir(), `${session}.port`);
}
export function cleanupIpcFiles(session) {
const pidFile = getPidFile(session);
const portFile = getPortFile(session);
const sockPath = getUnixSocketPath(session);
try {
if (fs.existsSync(pidFile))
fs.unlinkSync(pidFile);
}
catch { }
try {
if (fs.existsSync(portFile))
fs.unlinkSync(portFile);
}
catch { }
try {
if (fs.existsSync(sockPath))
fs.unlinkSync(sockPath);
}
catch { }
}
/**
* Get port number for TCP mode (Windows).
* Uses a hash of the session name to get a consistent port.
*/
export function getPortForSession(session) {
let hash = 0;
for (let i = 0; i < session.length; i++) {
hash = (hash << 5) - hash + session.charCodeAt(i);
hash |= 0;
}
return 49152 + (Math.abs(hash) % 16383);
}

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
const commandSchema = z
.object({
id: z.string(),
action: z.string()
})
.passthrough();
export function parseCommand(input) {
let json;
try {
json = JSON.parse(input);
}
catch {
return { success: false, error: "Invalid JSON" };
}
const id = typeof json === "object" && json !== null && "id" in json
? String(json.id)
: undefined;
const result = commandSchema.safeParse(json);
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
return { success: false, error: `Validation error: ${errors}`, id };
}
return { success: true, command: result.data };
}
export function successResponse(id, data) {
return { id, success: true, data };
}
export function errorResponse(id, error) {
return { id, success: false, error };
}
/**
* Serialize a response to JSON string.
* Replaces lone Unicode surrogates with U+FFFD to avoid serde_json parse errors.
*/
function sanitizeUtf16(input) {
// Fast path: most strings contain no surrogate code units.
if (!/[\uD800-\uDFFF]/.test(input))
return input;
// Replace lone high/low surrogates. Valid surrogate pairs are preserved.
// Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toWellFormed
return input.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
}
export function serializeResponse(response) {
return JSON.stringify(response, (_key, value) => typeof value === "string" ? sanitizeUtf16(value) : value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
/**
* Enhanced snapshot with element refs for deterministic element selection.
*
* This is largely compatible with agent-browser's snapshot/ref workflow:
* - `snapshot -i` returns a tree with `[ref=e1]` markers.
* - Subsequent actions can target `@e1` deterministically.
*/
let refCounter = 0;
export function resetRefs() {
refCounter = 0;
}
function nextRef() {
return `e${++refCounter}`;
}
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem"
]);
const CONTENT_ROLES = new Set([
"heading",
"cell",
"gridcell",
"columnheader",
"rowheader",
"listitem",
"article",
"region",
"main",
"navigation"
]);
const STRUCTURAL_ROLES = new Set([
"generic",
"group",
"list",
"table",
"row",
"rowgroup",
"grid",
"treegrid",
"menu",
"menubar",
"toolbar",
"tablist",
"tree",
"directory",
"document",
"application",
"presentation",
"none"
]);
function buildSelector(role, name) {
const escapedName = JSON.stringify(name);
return `getByRole('${role}', { name: ${escapedName}, exact: true })`;
}
async function findCursorInteractiveElements(page, selector) {
const rootSelector = selector || "body";
const scriptBody = `(rootSel) => {
const results = [];
const interactiveRoles = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'searchbox',
'slider', 'spinbutton', 'switch', 'tab', 'treeitem'
]);
const interactiveTags = new Set([
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary'
]);
const root = document.querySelector(rootSel) || document.body;
const allElements = root.querySelectorAll('*');
const buildSelector = (el) => {
const testId = el.getAttribute('data-testid');
if (testId) return '[data-testid=\"' + testId + '\"]';
if (el.id) return '#' + CSS.escape(el.id);
const path = [];
let current = el;
while (current && current !== document.body) {
let sel = current.tagName.toLowerCase();
const classes = Array.from(current.classList).filter(c => c.trim());
if (classes.length > 0) sel += '.' + CSS.escape(classes[0]);
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const matching = siblings.filter(s => {
if (s.tagName !== current.tagName) return false;
if (classes.length > 0 && !s.classList.contains(classes[0])) return false;
return true;
});
if (matching.length > 1) {
const idx = matching.indexOf(current) + 1;
sel += ':nth-of-type(' + idx + ')';
}
}
path.unshift(sel);
current = current.parentElement;
if (path.length >= 1) {
try {
const candidate = path.join(' > ');
if (document.querySelectorAll(candidate).length === 1) break;
} catch (e) {}
}
if (path.length >= 10) break;
}
return path.join(' > ');
};
for (const el of allElements) {
const tagName = el.tagName.toLowerCase();
if (interactiveTags.has(tagName)) continue;
const role = el.getAttribute('role');
if (role && interactiveRoles.has(role.toLowerCase())) continue;
const computedStyle = getComputedStyle(el);
const hasCursorPointer = computedStyle.cursor === 'pointer';
const hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
const tabIndex = el.getAttribute('tabindex');
const hasTabIndex = tabIndex !== null && tabIndex !== '-1';
if (!hasCursorPointer && !hasOnClick && !hasTabIndex) continue;
if (hasCursorPointer && !hasOnClick && !hasTabIndex) {
const parent = el.parentElement;
if (parent && getComputedStyle(parent).cursor === 'pointer') continue;
}
const text = (el.textContent || '').trim().slice(0, 100);
if (!text) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
results.push({
selector: buildSelector(el),
text,
tagName,
hasOnClick,
hasCursorPointer,
hasTabIndex
});
}
return results;
}`;
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function("return " + scriptBody)();
return page.evaluate(fn, rootSelector);
}
export async function getEnhancedSnapshot(page, options = {}) {
resetRefs();
const refs = {};
const locator = options.selector ? page.locator(options.selector) : page.locator(":root");
const ariaTree = await locator.ariaSnapshot();
if (!ariaTree) {
return { tree: "(empty)", refs: {} };
}
const enhancedTree = processAriaTree(ariaTree, refs, options);
if (options.cursor) {
const cursorElements = await findCursorInteractiveElements(page, options.selector);
const existingTexts = new Set(Object.values(refs).map((r) => r.name.toLowerCase()));
for (const m of enhancedTree.matchAll(/"([^"]+)"/g)) {
existingTexts.add(m[1].toLowerCase());
}
const additionalLines = [];
for (const el of cursorElements) {
const elTextLower = el.text.toLowerCase();
if (existingTexts.has(elTextLower))
continue;
existingTexts.add(elTextLower);
const ref = nextRef();
const role = el.hasCursorPointer || el.hasOnClick ? "clickable" : "focusable";
refs[ref] = { selector: el.selector, role, name: el.text };
const hints = [];
if (el.hasCursorPointer)
hints.push("cursor:pointer");
if (el.hasOnClick)
hints.push("onclick");
if (el.hasTabIndex)
hints.push("tabindex");
additionalLines.push(`- ${role} "${el.text}" [ref=${ref}] [${hints.join(", ")}]`);
}
if (additionalLines.length > 0) {
const separator = enhancedTree === "(no interactive elements)"
? ""
: "\n# Cursor-interactive elements:\n";
const base = enhancedTree === "(no interactive elements)" ? "" : enhancedTree;
return { tree: base + separator + additionalLines.join("\n"), refs };
}
}
return { tree: enhancedTree, refs };
}
function createRoleNameTracker() {
const counts = new Map();
const refsByKey = new Map();
return {
counts,
refsByKey,
getKey(role, name) {
return `${role}:${name ?? ""}`;
},
getNextIndex(role, name) {
const key = this.getKey(role, name);
const current = counts.get(key) ?? 0;
counts.set(key, current + 1);
return current;
},
trackRef(role, name, ref) {
const key = this.getKey(role, name);
const list = refsByKey.get(key) ?? [];
list.push(ref);
refsByKey.set(key, list);
},
getDuplicateKeys() {
const duplicates = new Set();
for (const [key, list] of refsByKey) {
if (list.length > 1)
duplicates.add(key);
}
return duplicates;
}
};
}
function processAriaTree(ariaTree, refs, options) {
const lines = ariaTree.split("\n");
const result = [];
const tracker = createRoleNameTracker();
if (options.interactive) {
for (const line of lines) {
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match)
continue;
const [, , role, name, suffix] = match;
const roleLower = role.toLowerCase();
if (INTERACTIVE_ROLES.has(roleLower)) {
const ref = nextRef();
const resolvedName = name ?? "";
const nth = tracker.getNextIndex(roleLower, resolvedName);
tracker.trackRef(roleLower, resolvedName, ref);
refs[ref] = {
selector: buildSelector(roleLower, resolvedName),
role: roleLower,
name: resolvedName,
nth
};
let enhanced = `- ${role}`;
if (name)
enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`;
if (nth > 0)
enhanced += ` [nth=${nth}]`;
if (suffix && suffix.includes("["))
enhanced += suffix;
result.push(enhanced);
}
}
removeNthFromNonDuplicates(refs, tracker);
return result.join("\n") || "(no interactive elements)";
}
for (const line of lines) {
const processed = processLine(line, refs, options, tracker);
if (processed !== null)
result.push(processed);
}
removeNthFromNonDuplicates(refs, tracker);
if (options.compact) {
return compactTree(result.join("\n"));
}
return result.join("\n");
}
function removeNthFromNonDuplicates(refs, tracker) {
const duplicateKeys = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);
if (!duplicateKeys.has(key)) {
delete refs[ref].nth;
}
}
}
function getIndentLevel(line) {
const match = line.match(/^(\s*)/);
return match ? Math.floor(match[1].length / 2) : 0;
}
function processLine(line, refs, options, tracker) {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
if (options.interactive)
return null;
return line;
}
const [, prefix, role, name, suffix] = match;
const roleLower = role.toLowerCase();
if (role.startsWith("/"))
return line;
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
const isContent = CONTENT_ROLES.has(roleLower);
const isStructural = STRUCTURAL_ROLES.has(roleLower);
if (options.interactive && !isInteractive)
return null;
if (options.compact && isStructural && !name)
return null;
const shouldHaveRef = isInteractive || (isContent && name);
if (shouldHaveRef) {
const ref = nextRef();
const resolvedName = isInteractive ? (name ?? "") : name;
const nth = tracker.getNextIndex(roleLower, resolvedName);
tracker.trackRef(roleLower, resolvedName, ref);
refs[ref] = {
selector: buildSelector(roleLower, resolvedName),
role: roleLower,
name: resolvedName,
nth
};
let enhanced = `${prefix}${role}`;
if (name)
enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`;
if (nth > 0)
enhanced += ` [nth=${nth}]`;
if (suffix)
enhanced += suffix;
return enhanced;
}
return line;
}
function compactTree(tree) {
const lines = tree.split("\n");
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes("[ref=")) {
result.push(line);
continue;
}
if (line.includes(":") && !line.endsWith(":")) {
result.push(line);
continue;
}
const currentIndent = getIndentLevel(line);
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j++) {
const childIndent = getIndentLevel(lines[j]);
if (childIndent <= currentIndent)
break;
if (lines[j].includes("[ref=")) {
hasRelevantChildren = true;
break;
}
}
if (hasRelevantChildren) {
result.push(line);
}
}
return result.join("\n");
}
export function parseRef(arg) {
if (arg.startsWith("@"))
return arg.slice(1);
if (arg.startsWith("ref="))
return arg.slice(4);
if (/^e\\d+$/.test(arg))
return arg;
return null;
}