@angelerator/uuics-core
Version:
Universal UI Context System - AI-powered web interface understanding and interaction
1,630 lines (1,624 loc) • 74.2 kB
JavaScript
// src/utils.ts
function debounce(func, wait) {
let timeout = null;
let lastArgs = null;
const debounced = (...args) => {
lastArgs = args;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
timeout = null;
lastArgs = null;
}, wait);
};
debounced.cancel = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
lastArgs = null;
}
};
debounced.flush = () => {
if (timeout && lastArgs) {
clearTimeout(timeout);
func(...lastArgs);
timeout = null;
lastArgs = null;
}
};
return debounced;
}
function hash(str) {
let hash2 = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash2 = (hash2 << 5) - hash2 + char;
hash2 = hash2 & hash2;
}
return Math.abs(hash2).toString(36);
}
function generateId(prefix = "uuics") {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function isElementVisible(element) {
if (!element) return false;
const style = window.getComputedStyle(element);
if (style.display === "none") return false;
if (style.visibility === "hidden") return false;
if (parseFloat(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return false;
return true;
}
function getElementSelector(element) {
if (element.id) {
return `#${CSS.escape(element.id)}`;
}
if (element.getAttribute("name")) {
const name = element.getAttribute("name");
const tag2 = element.tagName.toLowerCase();
const selector = `${tag2}[name="${CSS.escape(name)}"]`;
if (document.querySelectorAll(selector).length === 1) {
return selector;
}
}
const tag = element.tagName.toLowerCase();
if (element.className && typeof element.className === "string") {
const classes = element.className.split(" ").filter((c) => c.trim());
for (const cls of classes) {
if (!cls.match(/^(flex|grid|p-|m-|text-|bg-|border-|rounded-|w-|h-|min-|max-|gap-|space-|items-|justify-|animate-)/)) {
const selector = `${tag}.${CSS.escape(cls)}`;
const matches = document.querySelectorAll(selector);
if (matches.length === 1) {
return selector;
}
}
}
}
const dataAttrs = Array.from(element.attributes).filter((attr) => attr.name.startsWith("data-"));
for (const attr of dataAttrs) {
const selector = `${tag}[${attr.name}="${CSS.escape(attr.value)}"]`;
if (document.querySelectorAll(selector).length === 1) {
return selector;
}
}
const path = [];
let current = element;
let depth = 0;
const maxDepth = 5;
while (current && current !== document.body && depth < maxDepth) {
let selector = current.tagName.toLowerCase();
if (current.id) {
path.unshift(`#${CSS.escape(current.id)}`);
break;
}
let unique = false;
const name = current.getAttribute("name");
if (name) {
selector += `[name="${CSS.escape(name)}"]`;
unique = true;
}
if (!unique && current.parentElement) {
const siblings = Array.from(current.parentElement.children);
const sameTagSiblings = siblings.filter(
(s) => s.tagName === current.tagName && (!name || s.getAttribute("name") === name)
);
if (sameTagSiblings.length > 1) {
const index = sameTagSiblings.indexOf(current) + 1;
selector += `:nth-of-type(${index})`;
}
}
path.unshift(selector);
current = current.parentElement;
depth++;
if (unique) {
const testSelector = path.join(" > ");
if (document.querySelectorAll(testSelector).length === 1) {
break;
}
}
}
return path.join(" > ");
}
function runInIdle(callback) {
if ("requestIdleCallback" in window) {
requestIdleCallback(callback);
} else {
setTimeout(callback, 0);
}
}
function getElementBounds(element) {
try {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
};
} catch {
return {
x: 0,
y: 0,
width: 0,
height: 0
};
}
}
// src/scanner/DOMScanner.ts
var DOMScanner = class {
constructor(config) {
this.elementCount = 0;
this.config = {
depth: config?.scan?.depth ?? 10,
includeHidden: config?.scan?.includeHidden ?? false,
includeDisabled: config?.scan?.includeDisabled ?? false,
includeBounds: config?.serialize?.includeBounds ?? false,
rootSelectors: this.parseSelectors(config?.scan?.rootSelectors),
excludeSelectors: this.parseSelectors(config?.scan?.excludeSelectors),
includePatterns: this.parsePatterns(config?.scan?.includePatterns),
excludePatterns: this.parsePatterns(config?.scan?.excludePatterns),
includeElements: this.parseElements(config?.scan?.includeElements),
excludeElements: this.parseElements(config?.scan?.excludeElements),
filter: config?.scan?.filter,
maxElements: config?.performance?.maxElements ?? 1e3
};
this.cache = /* @__PURE__ */ new WeakMap();
}
/**
* Parse selector input - accepts string (comma-separated) or array
*/
parseSelectors(input) {
if (!input) return void 0;
if (typeof input === "string") {
return input.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
}
return input.map((s) => s.trim()).filter((s) => s.length > 0);
}
/**
* Parse regex patterns - accepts single RegExp or array
*/
parsePatterns(input) {
if (!input) return void 0;
return Array.isArray(input) ? input : [input];
}
/**
* Parse element types - accepts string (comma-separated) or array
*/
parseElements(input) {
if (!input) return void 0;
if (typeof input === "string") {
return input.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0);
}
return input.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0);
}
/**
* Get element info string for regex matching
* Returns a string containing selector, id, and classes
*/
getElementInfo(element) {
const parts = [];
parts.push(element.tagName.toLowerCase());
if (element.id) {
parts.push(`#${element.id}`);
}
if (element.className && typeof element.className === "string") {
const classes = element.className.split(/\s+/).filter((c) => c.length > 0);
classes.forEach((c) => parts.push(`.${c}`));
}
Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith("data-")) {
parts.push(`[${attr.name}="${attr.value}"]`);
}
});
return parts.join(" ");
}
/**
* Scan the DOM and return all interactive elements
*/
scan(root, configOverride) {
const startTime = performance.now();
this.elementCount = 0;
const parsedOverride = configOverride ? {
...configOverride,
rootSelectors: configOverride.rootSelectors ? this.parseSelectors(configOverride.rootSelectors) : void 0,
excludeSelectors: configOverride.excludeSelectors ? this.parseSelectors(configOverride.excludeSelectors) : void 0,
includePatterns: configOverride.includePatterns ? this.parsePatterns(configOverride.includePatterns) : void 0,
excludePatterns: configOverride.excludePatterns ? this.parsePatterns(configOverride.excludePatterns) : void 0,
includeElements: configOverride.includeElements ? this.parseElements(configOverride.includeElements) : void 0,
excludeElements: configOverride.excludeElements ? this.parseElements(configOverride.excludeElements) : void 0
} : void 0;
const scanConfig = parsedOverride ? { ...this.config, ...parsedOverride } : this.config;
const previousConfig = this.config;
this.config = scanConfig;
let elements = [];
if (scanConfig.rootSelectors && scanConfig.rootSelectors.length > 0) {
for (const selector of scanConfig.rootSelectors) {
const roots = document.querySelectorAll(selector);
for (const rootElement of Array.from(roots)) {
if (rootElement instanceof HTMLElement) {
elements.push(...this.scanRecursive(rootElement, 0));
}
}
}
} else {
const scanRoot = root || document.body;
elements = this.scanRecursive(scanRoot, 0);
}
this.config = previousConfig;
const duration = performance.now() - startTime;
{
console.debug(`[UUICS Scanner] Scanned ${this.elementCount} elements in ${duration.toFixed(2)}ms`);
}
return elements;
}
/**
* Recursively scan DOM tree
*/
scanRecursive(element, depth) {
const elements = [];
if (depth > this.config.depth) {
return elements;
}
if (this.elementCount >= this.config.maxElements) {
return elements;
}
const tagName = element.tagName.toLowerCase();
let elementInfo = null;
if (this.config.excludeSelectors && this.config.excludeSelectors.length > 0) {
for (const excludeSelector of this.config.excludeSelectors) {
if (element.matches(excludeSelector)) {
return elements;
}
}
}
if (this.config.excludeElements && this.config.excludeElements.includes(tagName)) {
return elements;
}
if (this.config.excludePatterns && this.config.excludePatterns.length > 0) {
elementInfo = this.getElementInfo(element);
for (const pattern of this.config.excludePatterns) {
if (pattern.test(elementInfo)) {
return elements;
}
}
}
const children = element.children;
const childrenLength = children.length;
if (this.config.includeElements && this.config.includeElements.length > 0) {
if (!this.config.includeElements.includes(tagName)) {
for (let i = 0; i < childrenLength; i++) {
if (this.elementCount >= this.config.maxElements) break;
elements.push(...this.scanRecursive(children[i], depth + 1));
}
return elements;
}
}
if (this.config.includePatterns && this.config.includePatterns.length > 0) {
if (!elementInfo) elementInfo = this.getElementInfo(element);
let matchesInclude = false;
for (const pattern of this.config.includePatterns) {
if (pattern.test(elementInfo)) {
matchesInclude = true;
break;
}
}
if (!matchesInclude) {
for (let i = 0; i < childrenLength; i++) {
if (this.elementCount >= this.config.maxElements) break;
elements.push(...this.scanRecursive(children[i], depth + 1));
}
return elements;
}
}
let shouldIncludeElement = true;
if (!this.config.includeHidden && !isElementVisible(element)) {
shouldIncludeElement = false;
}
if (shouldIncludeElement && this.config.filter && !this.config.filter(element)) {
shouldIncludeElement = false;
}
if (shouldIncludeElement) {
const uiElement = this.analyzeElement(element);
if (uiElement) {
elements.push(uiElement);
this.elementCount++;
}
}
for (let i = 0; i < childrenLength; i++) {
if (this.elementCount >= this.config.maxElements) break;
elements.push(...this.scanRecursive(children[i], depth + 1));
}
return elements;
}
/**
* Analyze a single element and determine if it's interactive
*/
analyzeElement(element) {
const type = this.getElementType(element);
const elementHash = this.hashElement(element);
const cached = this.cache.get(element);
if (cached && cached.hash === elementHash) {
return {
...cached.element,
metadata: {
...cached.element.metadata,
lastUpdated: Date.now()
}
};
}
const uiElement = this.buildUIElement(element, type);
this.cache.set(element, {
element: uiElement,
hash: elementHash,
timestamp: Date.now()
});
return uiElement;
}
/**
* Build UIElement object from HTMLElement
*/
buildUIElement(element, type) {
const tag = element.tagName.toLowerCase();
const selector = getElementSelector(element);
const label = this.getElementLabel(element);
const attributes = this.getRelevantAttributes(element);
const value = this.getElementValue(element);
const text = this.getElementText(element);
const visible = isElementVisible(element);
const enabled = !this.isDisabled(element);
const uiElement = {
id: element.id || generateId("element"),
type,
tag,
selector,
label,
attributes,
value,
text,
visible,
enabled,
metadata: {
hash: this.hashElement(element),
lastUpdated: Date.now()
}
};
if (type === "select" && element instanceof HTMLSelectElement) {
const options = this.extractSelectOptions(element);
const selectMetadata = this.extractSelectMetadata(element, options);
uiElement.options = options;
uiElement.selectMetadata = selectMetadata;
uiElement.children = options.map((opt) => this.createOptionElement(opt, element));
uiElement.metadata.options = options;
uiElement.metadata.selectMetadata = selectMetadata;
}
if (this.config.includeBounds) {
uiElement.bounds = getElementBounds(element);
}
return uiElement;
}
/**
* Extract options from a select element
*/
extractSelectOptions(selectElement) {
const options = [];
const optionElements = Array.from(selectElement.options);
optionElements.forEach((option, index) => {
options.push({
value: option.value,
label: option.label || option.text,
selected: option.selected,
disabled: option.disabled,
text: option.text,
index
});
});
return options;
}
/**
* Extract metadata for a select element
*/
extractSelectMetadata(selectElement, options) {
const selectedValues = options.filter((opt) => opt.selected).map((opt) => opt.value);
return {
options,
multiple: selectElement.multiple,
selectedValues,
size: selectElement.size > 0 ? selectElement.size : void 0
};
}
/**
* Create a UIElement for an option
*/
createOptionElement(option, selectElement) {
return {
id: generateId("option"),
type: "other",
tag: "option",
selector: `${getElementSelector(selectElement)} > option:nth-child(${option.index + 1})`,
label: option.label,
attributes: {
value: option.value,
selected: option.selected,
disabled: option.disabled
},
value: option.value,
text: option.text,
visible: true,
enabled: !option.disabled,
metadata: {
hash: hash(`option-${option.value}-${option.selected}`),
lastUpdated: Date.now()
}
};
}
/**
* Determine element type
*/
getElementType(element) {
const tag = element.tagName.toLowerCase();
const type = element.getAttribute("type")?.toLowerCase();
const role = element.getAttribute("role")?.toLowerCase();
const contentEditable = element.getAttribute("contenteditable");
if (role) {
if (role === "button") return "button";
if (role === "link") return "link";
if (role === "textbox") return "input";
if (role === "checkbox") return "checkbox";
if (role === "radio") return "radio";
if (role === "combobox" || role === "listbox") return "select";
}
if (contentEditable === "true" || contentEditable === "") {
return "input";
}
if (tag === "button") return "button";
if (tag === "a") return "link";
if (tag === "select") return "select";
if (tag === "textarea") return "textarea";
if (tag === "form") return "form";
if (tag === "datalist") return "select";
if (tag === "output") return "text";
if (tag === "meter" || tag === "progress") return "other";
if (tag === "details") return "button";
if (tag === "dialog") return "container";
if (tag === "fieldset") return "container";
if (tag === "input") {
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
if (type === "submit" || type === "button") return "button";
return "input";
}
if (element.hasAttribute("onclick") || element.hasAttribute("ng-click")) {
return "button";
}
if (this.isContainer(element)) {
return "container";
}
if (this.hasSignificantText(element)) {
return "text";
}
return "other";
}
/**
* Check if element is a container
*/
isContainer(element) {
const tag = element.tagName.toLowerCase();
return ["div", "section", "article", "main", "aside", "nav", "header", "footer"].includes(tag) && element.children.length > 0;
}
/**
* Check if element has significant text content
*/
hasSignificantText(element) {
const text = element.textContent?.trim() || "";
return text.length > 3 && !element.querySelector("input, button, select, textarea, a");
}
/**
* Get human-readable label for element
*/
getElementLabel(element) {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
const label = element.labels?.[0];
let labelText = label?.textContent?.trim() || "";
if (element instanceof HTMLInputElement) {
const type = element.type;
if (labelText && labelText.split(" ").length <= 2) {
switch (type) {
case "date":
labelText = `${labelText} (Date Picker)`;
break;
case "time":
labelText = `${labelText} (Time Picker)`;
break;
case "color":
labelText = `${labelText} (Color Picker)`;
break;
case "range":
labelText = `${labelText} (Slider)`;
break;
case "file":
labelText = `${labelText} (File Upload)`;
break;
}
}
}
if (labelText) return labelText;
}
const ariaLabel = element.getAttribute("aria-label");
if (ariaLabel) return ariaLabel;
const ariaLabelledBy = element.getAttribute("aria-labelledby");
if (ariaLabelledBy) {
const labelElement = document.getElementById(ariaLabelledBy);
if (labelElement?.textContent) return labelElement.textContent.trim();
}
const placeholder = element.getAttribute("placeholder");
if (placeholder) return placeholder;
const name = element.getAttribute("name");
if (name) return name;
const title = element.getAttribute("title");
if (title) return title;
const tag = element.tagName.toLowerCase();
if (["button", "a"].includes(tag)) {
const text = element.textContent?.trim();
if (text) return text;
}
return tag;
}
/**
* Get relevant HTML attributes
*/
getRelevantAttributes(element) {
const attrs = {};
const relevantAttrs = [
"type",
"name",
"placeholder",
"required",
"disabled",
"readonly",
"maxlength",
"pattern",
"min",
"max",
"step",
"role",
"contenteditable",
"aria-label",
"aria-labelledby",
"aria-describedby",
"aria-checked",
"aria-selected",
"aria-expanded",
"aria-pressed",
"open",
"value",
"multiple"
];
for (const attr of relevantAttrs) {
if (element.hasAttribute(attr)) {
const value = element.getAttribute(attr);
if (["required", "disabled", "readonly", "contenteditable", "multiple", "open"].includes(attr)) {
attrs[attr] = value === "true" || value === "" || value === attr;
} else if (value !== null) {
const numValue = parseFloat(value);
attrs[attr] = isNaN(numValue) ? value : numValue;
}
}
}
return attrs;
}
/**
* Get element value
*/
getElementValue(element) {
if (element.getAttribute("contenteditable") === "true" || element.getAttribute("contenteditable") === "") {
return element.textContent?.trim() || "";
}
if (element instanceof HTMLInputElement) {
if (element.type === "checkbox" || element.type === "radio") {
return element.checked;
}
if (element.type === "number") {
return element.valueAsNumber;
}
return element.value;
}
if (element instanceof HTMLTextAreaElement) {
return element.value;
}
if (element instanceof HTMLSelectElement) {
if (element.multiple) {
return Array.from(element.selectedOptions).map((opt) => opt.value);
}
return element.value;
}
if (element instanceof HTMLProgressElement) {
return element.value;
}
if (element instanceof HTMLMeterElement) {
return element.value;
}
if (element.tagName.toLowerCase() === "output") {
return element.textContent?.trim() || "";
}
const role = element.getAttribute("role");
if (role === "textbox" || role === "searchbox") {
return element.textContent?.trim() || "";
}
if (role === "checkbox" || role === "radio" || role === "switch") {
const checked = element.getAttribute("aria-checked");
return checked === "true";
}
return void 0;
}
/**
* Get element text content
*/
getElementText(element) {
const text = element.textContent?.trim();
return text && text.length > 0 ? text : void 0;
}
/**
* Check if element is disabled
*/
isDisabled(element) {
return element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true" || element.disabled;
}
/**
* Generate hash for element state (for change detection)
*/
hashElement(element) {
const parts = [
element.tagName,
element.id,
element.className,
element.getAttribute("name") || "",
this.getElementValue(element)?.toString() || "",
element.textContent?.trim() || ""
];
return hash(parts.join("|"));
}
/**
* Clear the cache
*/
clearCache() {
this.cache = /* @__PURE__ */ new WeakMap();
}
/**
* Update configuration
*/
updateConfig(config) {
this.config = { ...this.config, ...config };
}
};
// src/tracker/MutationTracker.ts
var MutationTracker = class {
constructor(config) {
this.observer = null;
this.listeners = /* @__PURE__ */ new Map();
this.changeCallback = null;
this.debouncedOnChange = null;
this.isActive = false;
this.config = {
mutations: config?.track?.mutations ?? true,
clicks: config?.track?.clicks ?? true,
changes: config?.track?.changes ?? true,
submits: config?.track?.submits ?? true,
debounceDelay: config?.track?.debounceDelay ?? 100
};
}
/**
* Start tracking
*/
start(callback) {
if (this.isActive) {
console.warn("[UUICS Tracker] Already active");
return;
}
this.changeCallback = callback;
this.debouncedOnChange = debounce(
(trigger, target) => {
if (this.changeCallback) {
this.changeCallback(trigger, target);
}
},
this.config.debounceDelay
);
if (this.config.mutations) {
this.setupMutationObserver();
}
if (this.config.clicks) {
this.setupClickListener();
}
if (this.config.changes) {
this.setupChangeListener();
}
if (this.config.submits) {
this.setupSubmitListener();
}
this.isActive = true;
{
console.debug("[UUICS Tracker] Started tracking");
}
}
/**
* Stop tracking
*/
stop() {
if (!this.isActive) {
return;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.listeners.forEach((listener, eventType) => {
document.body.removeEventListener(eventType, listener, true);
});
this.listeners.clear();
if (this.debouncedOnChange) {
this.debouncedOnChange.cancel();
this.debouncedOnChange = null;
}
this.changeCallback = null;
this.isActive = false;
{
console.debug("[UUICS Tracker] Stopped tracking");
}
}
/**
* Setup mutation observer for DOM changes
*/
setupMutationObserver() {
this.observer = new MutationObserver((mutations) => {
const relevantMutations = mutations.filter((mutation) => {
if (mutation.target instanceof HTMLElement) {
const id = mutation.target.id;
if (id && id.startsWith("uuics-")) {
return false;
}
}
if (mutation.type === "childList") {
return mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0;
}
if (mutation.type === "attributes") {
const target = mutation.target;
return this.isInteractiveElement(target);
}
return false;
});
if (relevantMutations.length > 0 && this.debouncedOnChange) {
const firstTarget = relevantMutations[0].target;
this.debouncedOnChange("mutation", firstTarget);
}
});
this.observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["value", "disabled", "checked", "selected", "class", "style"]
});
}
/**
* Setup click event listener
*/
setupClickListener() {
const listener = (event) => {
const target = event.target;
if (this.isInteractiveElement(target) && this.debouncedOnChange) {
this.debouncedOnChange("click", target);
}
};
document.body.addEventListener("click", listener, true);
this.listeners.set("click", listener);
}
/**
* Setup change event listener
*/
setupChangeListener() {
const listener = (event) => {
const target = event.target;
if (this.isFormControl(target) && this.debouncedOnChange) {
this.debouncedOnChange("change", target);
}
};
document.body.addEventListener("input", listener, true);
document.body.addEventListener("change", listener, true);
this.listeners.set("input", listener);
}
/**
* Setup submit event listener
*/
setupSubmitListener() {
const listener = (event) => {
const target = event.target;
if (target instanceof HTMLFormElement && this.debouncedOnChange) {
this.debouncedOnChange("submit", target);
}
};
document.body.addEventListener("submit", listener, true);
this.listeners.set("submit", listener);
}
/**
* Check if element is interactive
*/
isInteractiveElement(element) {
const tag = element.tagName.toLowerCase();
const interactiveTags = ["button", "a", "input", "select", "textarea"];
if (interactiveTags.includes(tag)) {
return true;
}
if (element.hasAttribute("onclick") || element.hasAttribute("ng-click") || element.getAttribute("role") === "button") {
return true;
}
return false;
}
/**
* Check if element is a form control
*/
isFormControl(element) {
return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement;
}
/**
* Flush any pending changes immediately
*/
flush() {
if (this.debouncedOnChange) {
this.debouncedOnChange.flush();
}
}
/**
* Update configuration
*/
updateConfig(config) {
const wasActive = this.isActive;
const callback = this.changeCallback;
if (wasActive) {
this.stop();
}
this.config = { ...this.config, ...config };
if (wasActive && callback) {
this.start(callback);
}
}
/**
* Check if tracker is active
*/
get active() {
return this.isActive;
}
};
// src/tracker/StateTracker.ts
var StateTracker = class {
constructor(config) {
this.trackedObjects = /* @__PURE__ */ new Map();
this.stateGetters = /* @__PURE__ */ new Map();
this.excludePatterns = [];
this.captureFunction = config?.capture;
this.excludePatterns = config?.exclude ?? [];
if (config?.track) {
for (const [name, obj] of Object.entries(config.track)) {
this.track(name, obj);
}
}
}
/**
* Track an object with Proxy for automatic change detection
*/
track(name, obj) {
const proxy = new Proxy(obj, {
set: (target, property, value) => {
target[property] = value;
return true;
},
get: (target, property) => {
return target[property];
}
});
this.trackedObjects.set(name, proxy);
return proxy;
}
/**
* Register a state getter function for manual state retrieval
*/
register(name, getter) {
this.stateGetters.set(name, getter);
}
/**
* Unregister a state getter
*/
unregister(name) {
this.stateGetters.delete(name);
}
/**
* Remove a tracked object
*/
untrack(name) {
this.trackedObjects.delete(name);
}
/**
* Capture current state snapshot
*/
captureSnapshot() {
const snapshot = {};
for (const [name, obj] of this.trackedObjects) {
snapshot[name] = this.deepClone(obj);
}
for (const [name, getter] of this.stateGetters) {
try {
snapshot[name] = this.deepClone(getter());
} catch (error) {
snapshot[name] = { error: error instanceof Error ? error.message : String(error) };
}
}
if (this.captureFunction) {
try {
const customState = this.captureFunction();
Object.assign(snapshot, customState);
} catch (error) {
console.warn("[StateTracker] Custom capture function failed:", error);
}
}
return this.applyExclusions(snapshot);
}
/**
* Deep clone an object with circular reference handling
*/
deepClone(obj, seen = /* @__PURE__ */ new WeakSet()) {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (seen.has(obj)) {
return "[Circular]";
}
if (obj instanceof Date) {
return obj.toISOString();
}
if (obj instanceof RegExp) {
return obj.toString();
}
if (Array.isArray(obj)) {
seen.add(obj);
const arr = obj.map((item) => this.deepClone(item, seen));
seen.delete(obj);
return arr;
}
if (typeof obj === "function") {
return "[Function]";
}
try {
seen.add(obj);
const cloned = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
try {
cloned[key] = this.deepClone(obj[key], seen);
} catch (error) {
cloned[key] = "[Error cloning]";
}
}
}
seen.delete(obj);
return cloned;
} catch (error) {
return "[Unserializable]";
}
}
/**
* Apply exclusion filters to remove sensitive data
*/
applyExclusions(snapshot) {
if (this.excludePatterns.length === 0) {
return snapshot;
}
const filtered = { ...snapshot };
for (const pattern of this.excludePatterns) {
this.filterByPattern(filtered, pattern);
}
return filtered;
}
/**
* Recursively filter object by pattern
*/
filterByPattern(obj, pattern, path = "") {
if (obj === null || typeof obj !== "object") {
return;
}
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
continue;
}
const currentPath = path ? `${path}.${key}` : key;
if (this.matchesPattern(currentPath, pattern) || this.matchesPattern(key, pattern)) {
obj[key] = "[EXCLUDED]";
continue;
}
if (typeof obj[key] === "object" && obj[key] !== null) {
this.filterByPattern(obj[key], pattern, currentPath);
}
}
}
/**
* Check if a string matches a pattern (supports wildcards)
*/
matchesPattern(str, pattern) {
const regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
const regex = new RegExp(`^${regexPattern}$`, "i");
return regex.test(str.toLowerCase());
}
/**
* Get list of tracked state names
*/
getTrackedNames() {
const names = [];
names.push(...this.trackedObjects.keys());
names.push(...this.stateGetters.keys());
return names;
}
/**
* Clear all tracked state
*/
clear() {
this.trackedObjects.clear();
this.stateGetters.clear();
}
};
// src/aggregator/ContextAggregator.ts
var ContextAggregator = class {
/**
* Aggregate UI elements into a complete page context
*/
aggregate(elements, metadata) {
const context = {
id: generateId("context"),
timestamp: Date.now(),
url: window.location.href,
title: document.title,
elements,
actions: this.generateActions(elements),
forms: this.extractForms(elements),
metadata: {
elementCount: elements.length,
scanDepth: metadata.scanDepth,
scanDuration: metadata.scanDuration,
partial: metadata.partial
}
};
return context;
}
/**
* Generate available actions from elements
*/
generateActions(elements) {
const actions = [];
for (const element of elements) {
if (!element.enabled || !element.visible) {
continue;
}
const elementActions = this.getActionsForElement(element);
actions.push(...elementActions);
}
return actions;
}
/**
* Get actions for a specific element
*/
getActionsForElement(element) {
const actions = [];
switch (element.type) {
case "button":
case "link":
actions.push(this.createAction("click", element, "Click"));
break;
case "input":
case "textarea":
actions.push(this.createAction("setValue", element, "Set value", {
value: {
type: "string",
description: "The value to set",
required: true
}
}));
actions.push(this.createAction("focus", element, "Focus"));
break;
case "checkbox":
actions.push(this.createAction("check", element, "Check"));
actions.push(this.createAction("uncheck", element, "Uncheck"));
break;
case "radio":
actions.push(this.createAction("select", element, "Select"));
break;
case "select":
actions.push(this.createAction("select", element, "Select option", {
value: {
type: "string",
description: "Option value to select",
required: true
}
}));
break;
case "form":
actions.push(this.createAction("submit", element, "Submit form"));
break;
}
return actions;
}
/**
* Create an action object
*/
createAction(type, element, actionLabel, parameters) {
const description = `${actionLabel} ${element.label || element.selector}`;
return {
id: generateId("action"),
type,
description,
target: element.selector,
parameters,
available: element.enabled && element.visible
};
}
/**
* Extract form states from elements
*/
extractForms(elements) {
const forms = [];
const formElements = elements.filter((el) => el.type === "form");
for (const formElement of formElements) {
const formControls = elements.filter((el) => {
return el.selector.startsWith(formElement.selector) && ["input", "textarea", "select", "checkbox", "radio"].includes(el.type);
});
const fields = {};
const errors = {};
let valid = true;
for (const control of formControls) {
const fieldName = control.attributes.name || control.id;
fields[fieldName] = control.value;
if (control.attributes.required && !control.value) {
errors[fieldName] = "This field is required";
valid = false;
}
if (control.type === "input" && control.attributes.pattern && control.value) {
try {
const pattern = new RegExp(control.attributes.pattern);
if (!pattern.test(String(control.value))) {
errors[fieldName] = "Invalid format";
valid = false;
}
} catch (e) {
}
}
}
forms.push({
id: formElement.id,
selector: formElement.selector,
fields,
valid,
errors: Object.keys(errors).length > 0 ? errors : void 0
});
}
return forms;
}
/**
* Generate action registry (for documentation/introspection)
*/
generateActionRegistry(context) {
const registry = {};
for (const action of context.actions) {
if (!registry[action.type]) {
registry[action.type] = [];
}
registry[action.type].push(action);
}
return registry;
}
/**
* Find actions by type
*/
findActionsByType(context, type) {
return context.actions.filter((action) => action.type === type);
}
/**
* Find action by target
*/
findActionByTarget(context, target) {
return context.actions.find((action) => action.target === target);
}
/**
* Get suggested actions (most common/important actions)
*/
getSuggestedActions(context, limit = 5) {
const priority = ["submit", "click", "setValue", "select", "check"];
const suggested = [];
for (const type of priority) {
const actionsOfType = this.findActionsByType(context, type);
suggested.push(...actionsOfType);
if (suggested.length >= limit) {
break;
}
}
return suggested.slice(0, limit);
}
};
// src/serializer/Serializer.ts
var Serializer = class {
/**
* Serialize context to specified format
*/
serialize(context, format = "json", options = {}) {
let content;
switch (format) {
case "json":
content = this.toJSON(context, options);
break;
case "natural":
content = this.toNaturalLanguage(context, options);
break;
case "openapi":
content = this.toOpenAPI(context, options);
break;
default:
throw new Error(`Unsupported format: ${format}`);
}
return {
format,
content,
timestamp: Date.now(),
metadata: options.includeMetadata ? context.metadata : void 0
};
}
/**
* Serialize to JSON
*/
toJSON(context, options) {
const data = {
id: context.id,
timestamp: context.timestamp,
url: context.url,
title: context.title,
elements: options.includeBounds ? context.elements : context.elements.map((el) => {
const { bounds, ...rest } = el;
return rest;
}),
actions: context.actions,
forms: context.forms,
metadata: context.metadata,
state: context.state
};
try {
return JSON.stringify(data, this.createCircularReplacer(), options.pretty ? 2 : void 0);
} catch (error) {
console.warn("Failed to serialize with state, retrying without state:", error);
const dataWithoutState = { ...data, state: void 0 };
return JSON.stringify(dataWithoutState, this.createCircularReplacer(), options.pretty ? 2 : void 0);
}
}
/**
* Create a replacer function that handles circular references and limits depth
*/
createCircularReplacer() {
const seen = /* @__PURE__ */ new WeakSet();
let depth = 0;
const maxDepth = 50;
return (_key, value) => {
if (typeof value === "object" && value !== null) {
if (depth > maxDepth) {
return "[Max Depth Exceeded]";
}
if (seen.has(value)) {
return "[Circular Reference]";
}
seen.add(value);
depth++;
const result = value;
depth--;
return result;
}
if (typeof value === "string" && value.length > 1e4) {
return value.substring(0, 1e4) + "... [truncated]";
}
return value;
};
}
/**
* Serialize to natural language (LLM-friendly)
*/
toNaturalLanguage(context, options) {
const lines = [];
lines.push("# Page Context\n");
lines.push(`Page: ${context.title}`);
lines.push(`URL: ${context.url}`);
lines.push(`Timestamp: ${new Date(context.timestamp).toISOString()}`);
lines.push("");
const actionableTypes = ["button", "input", "select", "textarea", "link", "checkbox", "radio", "switch"];
const filteredElements = this.deduplicateElements(
context.elements.filter((el) => actionableTypes.includes(el.type))
);
lines.push("## Summary\n");
lines.push(`Total Actionable Elements: ${filteredElements.length}`);
lines.push(`Available Actions: ${context.actions.length}`);
if (context.forms && context.forms.length > 0) {
lines.push(`Forms: ${context.forms.length}`);
}
lines.push("");
if (filteredElements.length > 0) {
lines.push("## Interactive Elements\n");
const groupedElements = this.groupElementsByType(filteredElements);
for (const [type, elements] of Object.entries(groupedElements)) {
if (elements.length === 0) continue;
lines.push(`### ${this.capitalizeFirst(type)}s (${elements.length})
`);
for (const element of elements.slice(0, 30)) {
lines.push(this.formatElementNatural(element));
}
if (elements.length > 30) {
lines.push(`... and ${elements.length - 30} more
`);
}
lines.push("");
}
}
if (context.forms && context.forms.length > 0) {
lines.push("## Forms\n");
for (const form of context.forms) {
lines.push(`### Form: ${form.id}`);
lines.push(`Valid: ${form.valid ? "Yes" : "No"}`);
lines.push(`Fields: ${Object.keys(form.fields).length}`);
if (form.errors && Object.keys(form.errors).length > 0) {
lines.push("\nErrors:");
for (const [field, error] of Object.entries(form.errors)) {
lines.push(`- ${field}: ${error}`);
}
}
lines.push("");
}
}
const deduplicatedActions = this.deduplicateActions(context.actions);
if (deduplicatedActions.length > 0) {
lines.push("## Available Actions\n");
const groupedActions = this.groupActionsByType(deduplicatedActions);
for (const [type, actions] of Object.entries(groupedActions)) {
if (actions.length === 0) continue;
lines.push(`### ${this.capitalizeFirst(type)} (${actions.length})
`);
for (const action of actions.slice(0, 30)) {
lines.push(`- ${action.description} (target: \`${action.target}\`)`);
}
if (actions.length > 30) {
lines.push(`... and ${actions.length - 30} more`);
}
lines.push("");
}
}
if (options.includeMetadata && context.metadata) {
lines.push("## Metadata\n");
lines.push(`Scan Duration: ${context.metadata.scanDuration.toFixed(2)}ms`);
lines.push(`Scan Depth: ${context.metadata.scanDepth}`);
lines.push(`Partial Scan: ${context.metadata.partial ? "Yes" : "No"}`);
}
if (context.state && Object.keys(context.state).length > 0) {
lines.push("");
lines.push("## Application State\n");
for (const [key, value] of Object.entries(context.state)) {
const valueStr = typeof value === "object" ? JSON.stringify(value, null, 2).split("\n").map((line, i) => i === 0 ? line : ` ${line}`).join("\n") : String(value);
lines.push(`### ${key}
`);
lines.push("```");
lines.push(valueStr);
lines.push("```");
lines.push("");
}
}
return lines.join("\n");
}
/**
* Serialize to OpenAPI format (tool calling)
*/
toOpenAPI(context, options) {
const tools = [];
const groupedActions = this.groupActionsByType(context.actions);
for (const [type, actions] of Object.entries(groupedActions)) {
if (actions.length === 0) continue;
const tool = {
type: "function",
function: {
name: `ui_${type}`,
description: `Perform ${type} action on a UI element`,
parameters: {
type: "object",
properties: {
target: {
type: "string",
description: "CSS selector of the target element",
enum: actions.map((a) => a.target)
}
},
required: ["target"]
}
}
};
const firstAction = actions[0];
if (firstAction.parameters) {
for (const [paramName, paramDef] of Object.entries(firstAction.parameters)) {
const param = paramDef;
tool.function.parameters.properties[paramName] = {
type: param.type,
description: param.description
};
if (param.enum) {
tool.function.parameters.properties[paramName].enum = param.enum;
}
if (param.required) {
if (!tool.function.parameters.required) {
tool.function.parameters.required = ["target"];
}
tool.function.parameters.required.push(paramName);
}
}
}
tools.push(tool);
}
const stateContext = context.state && Object.keys(context.state).length > 0 ? { state: context.state } : void 0;
return {
openapi: "3.1.0",
info: {
title: `UI Context: ${context.title}`,
version: "1.0.0",
description: `Available UI actions for ${context.url}`
},
servers: [
{
url: context.url
}
],
tools,
context: stateContext,
metadata: options.includeMetadata ? context.metadata : void 0
};
}
/**
* Format element for natural language output
*/
formatElementNatural(element) {
const parts = [];
const selectorHint = element.selector.includes("#") ? ` (${element.selector.split(/[>\s]/)[0]})` : "";
parts.push(`- **${element.label}${selectorHint}**`);
if (element.value !== void 0 && element.value !== "") {
parts.push(`(value: "${element.value}")`);
}
if (!element.enabled) {
if (element.type === "button") {
parts.push("\u26D4 **[DISABLED - DO NOT CLICK]**");
} else {
parts.push("[DISABLED]");
}
}
if (element.options && element.options.length > 0) {
const optionsList = element.options.map((opt) => `"${opt.label}" (value: ${opt.value})`).join(", ");
const isMultiSelect = element.selectMetadata?.multiple === true;
const selectType = isMultiSelect ? "MULTI-SELECT" : "SINGLE-SELECT";
parts.push(`[${selectType} OPTIONS: ${optionsList}]`);
}
parts.push(`\u2192 \`${element.selector}\``);
return parts.join(" ");
}
/**
* Group elements by type
*/
groupElementsByType(elements) {
const grouped = {};
for (const element of elements) {
if (!grouped[element.type]) {
grouped[element.type] = [];
}
grouped[element.type].push(element);
}
return grouped;
}
/**
* Group actions by type
*/
groupActionsByType(actions) {
const grouped = {};
for (const action of actions) {
if (!grouped[action.type]) {
grouped[action.type] = [];
}
grouped[action.type].push(action);
}
return grouped;
}
/**
* Capitalize first letter
*/
capitalizeFirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Deduplicate elements by selector
*/
deduplicateElements(elements) {
const seen = /* @__PURE__ */ new Set();
const unique = [];
for (const element of elements) {
if (!seen.has(element.selector)) {
seen.add(element.selector);
unique.push(element);
}
}
return unique;
}
/**
* Deduplicate actions by target selector
*/
deduplicateActions(actions) {
const seen = /* @__PURE__ */ new Set();
const unique = [];
for (const action of actions) {
const key = `${action.type}:${action.target}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(action);
}
}
return unique;
}
};
// src/utils/selectorSanitizer.ts
function sanitizeSelector(selector) {
const original = selector;
const warnings = [];
let cleaned = selector;
cleaned = cleaned.trim();
const dashPattern = /^[–\-\s]+|[–\-\s]+$/g;
if (dashPattern.test(cleaned)) {
warnings.push("Removed leading/trailing dashes");
cleaned = cleaned.replace(dashPattern, "");
}
const quotePattern = /^["'""'']+|["'""'']+$/g;
if (quotePattern.test(cleaned)) {
warnings.push("Removed surrounding quotes");
cleaned = cleaned.replace(quotePattern, "");
}
if (cleaned.includes('\\"') || cleaned.includes("\\'")) {
warnings.push("Removed escaped quotes");
cleaned = cleaned.replace(/\\["']/g, "");
}
if (/\s{2,}/.test(cleaned)) {
warnings.push("Collapsed multiple spaces");
cleaned = cleaned.replace(/\s{2,}/g, " ");
}
cleaned = cleaned.trim();
return {
selector: cleaned,
original,
modified: cleaned !== original,
warnings
};
}
function validateSelector(selector) {
if (!selector || selector.trim() === "") {
return { valid: false, error: "Selector is empty" };
}
const invalidPatterns = [
{ pattern: /^[–\-\s]+$/, error: "Selector contains only dashes and whitespace" },
{ pattern: /^["'""'']+$/, error: "Selector contains only quotes" },
{ pattern: /\s–\s/, error: "Selector contains em-dash surrounded by spaces (likely markdown artifact)" },
{ pattern: /^ACTION:/, error: "Selector appears to be an action command, not a CSS selector" }
];
for (const { pattern, error } of invalidPatterns) {
if (pattern.test(selector)) {
return { valid: false, error };
}
}
try {
const testDiv = document.createElement("div");
testDiv.querySelector(selector);
return { valid: true };
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : "Invalid CSS selector syntax"
};
}
}
function cleanAndValidateSelector(selector)