UNPKG

@angelerator/uuics-core

Version:

Universal UI Context System - AI-powered web interface understanding and interaction

1,630 lines (1,624 loc) 74.2 kB
// 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)