From cbd9ed7541c92dbb638e04afeb3f03af5b34b353 Mon Sep 17 00:00:00 2001 From: rcholic Date: Fri, 26 Dec 2025 04:38:38 +0000 Subject: [PATCH] chore: sync extension files from sentience-chrome v2.0.1 --- sentience/extension/background.js | 141 +++++-- sentience/extension/content.js | 99 +++-- sentience/extension/injected_api.js | 371 +++++++++++++++++- sentience/extension/manifest.json | 2 +- .../extension/pkg/sentience_core_bg.wasm | Bin 101498 -> 101498 bytes sentience/extension/release.json | 95 +++-- 6 files changed, 580 insertions(+), 128 deletions(-) diff --git a/sentience/extension/background.js b/sentience/extension/background.js index c108aed..811303f 100644 --- a/sentience/extension/background.js +++ b/sentience/extension/background.js @@ -53,44 +53,60 @@ initWASM().catch(err => { /** * Message handler for all extension communication + * Includes global error handling to prevent extension crashes */ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - // Handle screenshot requests (existing functionality) - if (request.action === 'captureScreenshot') { - handleScreenshotCapture(sender.tab.id, request.options) - .then(screenshot => { - sendResponse({ success: true, screenshot }); - }) - .catch(error => { - console.error('[Sentience Background] Screenshot capture failed:', error); - sendResponse({ - success: false, - error: error.message || 'Screenshot capture failed' + // Global error handler to prevent extension crashes + try { + // Handle screenshot requests (existing functionality) + if (request.action === 'captureScreenshot') { + handleScreenshotCapture(sender.tab.id, request.options) + .then(screenshot => { + sendResponse({ success: true, screenshot }); + }) + .catch(error => { + console.error('[Sentience Background] Screenshot capture failed:', error); + sendResponse({ + success: false, + error: error.message || 'Screenshot capture failed' + }); }); - }); - return true; // Async response - } + return true; // Async response + } - // Handle WASM processing requests (NEW!) - if (request.action === 'processSnapshot') { - handleSnapshotProcessing(request.rawData, request.options) - .then(result => { - sendResponse({ success: true, result }); - }) - .catch(error => { - console.error('[Sentience Background] Snapshot processing failed:', error); - sendResponse({ - success: false, - error: error.message || 'Snapshot processing failed' + // Handle WASM processing requests (NEW!) + if (request.action === 'processSnapshot') { + handleSnapshotProcessing(request.rawData, request.options) + .then(result => { + sendResponse({ success: true, result }); + }) + .catch(error => { + console.error('[Sentience Background] Snapshot processing failed:', error); + sendResponse({ + success: false, + error: error.message || 'Snapshot processing failed' + }); }); + return true; // Async response + } + + // Unknown action + console.warn('[Sentience Background] Unknown action:', request.action); + sendResponse({ success: false, error: 'Unknown action' }); + return false; + } catch (error) { + // Catch any synchronous errors that might crash the extension + console.error('[Sentience Background] Fatal error in message handler:', error); + try { + sendResponse({ + success: false, + error: `Fatal error: ${error.message || 'Unknown error'}` }); - return true; // Async response + } catch (e) { + // If sendResponse already called, ignore + } + return false; } - - // Unknown action - console.warn('[Sentience Background] Unknown action:', request.action); - sendResponse({ success: false, error: 'Unknown action' }); - return false; }); /** @@ -119,13 +135,27 @@ async function handleScreenshotCapture(_tabId, options = {}) { /** * Handle snapshot processing with WASM (NEW!) * This is where the magic happens - completely CSP-immune! + * Includes safeguards to prevent crashes and hangs. * * @param {Array} rawData - Raw element data from injected_api.js * @param {Object} options - Snapshot options (limit, filter, etc.) * @returns {Promise} Processed snapshot result */ async function handleSnapshotProcessing(rawData, options = {}) { + const MAX_ELEMENTS = 10000; // Safety limit to prevent hangs + const startTime = performance.now(); + try { + // Safety check: limit element count to prevent hangs + if (!Array.isArray(rawData)) { + throw new Error('rawData must be an array'); + } + + if (rawData.length > MAX_ELEMENTS) { + console.warn(`[Sentience Background] ⚠️ Large dataset: ${rawData.length} elements. Limiting to ${MAX_ELEMENTS} to prevent hangs.`); + rawData = rawData.slice(0, MAX_ELEMENTS); + } + // Ensure WASM is initialized await initWASM(); if (!wasmReady) { @@ -135,15 +165,35 @@ async function handleSnapshotProcessing(rawData, options = {}) { console.log(`[Sentience Background] Processing ${rawData.length} elements with options:`, options); // Run WASM processing using the imported functions directly + // Wrap in try-catch with timeout protection let analyzedElements; try { - if (options.limit || options.filter) { - analyzedElements = analyze_page_with_options(rawData, options); - } else { - analyzedElements = analyze_page(rawData); - } + // Use a timeout wrapper to prevent infinite hangs + const wasmPromise = new Promise((resolve, reject) => { + try { + let result; + if (options.limit || options.filter) { + result = analyze_page_with_options(rawData, options); + } else { + result = analyze_page(rawData); + } + resolve(result); + } catch (e) { + reject(e); + } + }); + + // Add timeout protection (18 seconds - less than content.js timeout) + analyzedElements = await Promise.race([ + wasmPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('WASM processing timeout (>18s)')), 18000) + ) + ]); } catch (e) { - throw new Error(`WASM analyze_page failed: ${e.message}`); + const errorMsg = e.message || 'Unknown WASM error'; + console.error(`[Sentience Background] WASM analyze_page failed: ${errorMsg}`, e); + throw new Error(`WASM analyze_page failed: ${errorMsg}`); } // Prune elements for API (prevents 413 errors on large sites) @@ -155,16 +205,29 @@ async function handleSnapshotProcessing(rawData, options = {}) { prunedRawData = rawData; } - console.log(`[Sentience Background] ✓ Processed: ${analyzedElements.length} analyzed, ${prunedRawData.length} pruned`); + const duration = performance.now() - startTime; + console.log(`[Sentience Background] ✓ Processed: ${analyzedElements.length} analyzed, ${prunedRawData.length} pruned (${duration.toFixed(1)}ms)`); return { elements: analyzedElements, raw_elements: prunedRawData }; } catch (error) { - console.error('[Sentience Background] Processing error:', error); + const duration = performance.now() - startTime; + console.error(`[Sentience Background] Processing error after ${duration.toFixed(1)}ms:`, error); throw error; } } console.log('[Sentience Background] Service worker ready'); + +// Global error handlers to prevent extension crashes +self.addEventListener('error', (event) => { + console.error('[Sentience Background] Global error caught:', event.error); + event.preventDefault(); // Prevent extension crash +}); + +self.addEventListener('unhandledrejection', (event) => { + console.error('[Sentience Background] Unhandled promise rejection:', event.reason); + event.preventDefault(); // Prevent extension crash +}); diff --git a/sentience/extension/content.js b/sentience/extension/content.js index cf6ba37..e625a77 100644 --- a/sentience/extension/content.js +++ b/sentience/extension/content.js @@ -45,39 +45,88 @@ function handleScreenshotRequest(data) { /** * Handle snapshot processing requests (NEW!) * Sends raw DOM data to background worker for WASM processing + * Includes timeout protection to prevent extension crashes */ function handleSnapshotRequest(data) { const startTime = performance.now(); + const TIMEOUT_MS = 20000; // 20 seconds (longer than injected_api timeout) + let responded = false; - chrome.runtime.sendMessage( - { - action: 'processSnapshot', - rawData: data.rawData, - options: data.options - }, - (response) => { + // Timeout protection: if background doesn't respond, send error + const timeoutId = setTimeout(() => { + if (!responded) { + responded = true; const duration = performance.now() - startTime; + console.error(`[Sentience Bridge] ⚠️ WASM processing timeout after ${duration.toFixed(1)}ms`); + window.postMessage({ + type: 'SENTIENCE_SNAPSHOT_RESULT', + requestId: data.requestId, + error: 'WASM processing timeout - background script may be unresponsive', + duration: duration + }, '*'); + } + }, TIMEOUT_MS); + + try { + chrome.runtime.sendMessage( + { + action: 'processSnapshot', + rawData: data.rawData, + options: data.options + }, + (response) => { + if (responded) return; // Already responded via timeout + responded = true; + clearTimeout(timeoutId); + + const duration = performance.now() - startTime; - if (response?.success) { - console.log(`[Sentience Bridge] ✓ WASM processing complete in ${duration.toFixed(1)}ms`); - window.postMessage({ - type: 'SENTIENCE_SNAPSHOT_RESULT', - requestId: data.requestId, - elements: response.result.elements, - raw_elements: response.result.raw_elements, - duration: duration - }, '*'); - } else { - console.error('[Sentience Bridge] WASM processing failed:', response?.error); - window.postMessage({ - type: 'SENTIENCE_SNAPSHOT_RESULT', - requestId: data.requestId, - error: response?.error || 'Processing failed', - duration: duration - }, '*'); + // Handle Chrome extension errors (e.g., background script crashed) + if (chrome.runtime.lastError) { + console.error('[Sentience Bridge] Chrome runtime error:', chrome.runtime.lastError.message); + window.postMessage({ + type: 'SENTIENCE_SNAPSHOT_RESULT', + requestId: data.requestId, + error: `Chrome runtime error: ${chrome.runtime.lastError.message}`, + duration: duration + }, '*'); + return; + } + + if (response?.success) { + console.log(`[Sentience Bridge] ✓ WASM processing complete in ${duration.toFixed(1)}ms`); + window.postMessage({ + type: 'SENTIENCE_SNAPSHOT_RESULT', + requestId: data.requestId, + elements: response.result.elements, + raw_elements: response.result.raw_elements, + duration: duration + }, '*'); + } else { + console.error('[Sentience Bridge] WASM processing failed:', response?.error); + window.postMessage({ + type: 'SENTIENCE_SNAPSHOT_RESULT', + requestId: data.requestId, + error: response?.error || 'Processing failed', + duration: duration + }, '*'); + } } + ); + } catch (error) { + if (!responded) { + responded = true; + clearTimeout(timeoutId); + const duration = performance.now() - startTime; + console.error('[Sentience Bridge] Exception sending message:', error); + window.postMessage({ + type: 'SENTIENCE_SNAPSHOT_RESULT', + requestId: data.requestId, + error: `Failed to send message: ${error.message}`, + duration: duration + }, '*'); } - ); + } } console.log('[Sentience Bridge] Ready - Extension ID:', chrome.runtime.id); diff --git a/sentience/extension/injected_api.js b/sentience/extension/injected_api.js index 80b59f6..8081667 100644 --- a/sentience/extension/injected_api.js +++ b/sentience/extension/injected_api.js @@ -63,20 +63,59 @@ return (el.innerText || '').replace(/\s+/g, ' ').trim().substring(0, 100); } - // --- HELPER: Safe Class Name Extractor --- + // --- HELPER: Safe Class Name Extractor (Handles SVGAnimatedString) --- function getClassName(el) { + if (!el || !el.className) return ''; + + // Handle string (HTML elements) if (typeof el.className === 'string') return el.className; - if (el.className && typeof el.className.baseVal === 'string') return el.className.baseVal; + + // Handle SVGAnimatedString (SVG elements) + if (typeof el.className === 'object') { + if ('baseVal' in el.className && typeof el.className.baseVal === 'string') { + return el.className.baseVal; + } + if ('animVal' in el.className && typeof el.className.animVal === 'string') { + return el.className.animVal; + } + // Fallback: convert to string + try { + return String(el.className); + } catch (e) { + return ''; + } + } + return ''; } - // --- HELPER: Safe String Converter --- + // --- HELPER: Paranoid String Converter (Handles SVGAnimatedString) --- function toSafeString(value) { if (value === null || value === undefined) return null; + + // 1. If it's already a primitive string, return it if (typeof value === 'string') return value; - if (value && typeof value === 'object' && 'baseVal' in value) { - return typeof value.baseVal === 'string' ? value.baseVal : null; + + // 2. Handle SVG objects (SVGAnimatedString, SVGAnimatedNumber, etc.) + if (typeof value === 'object') { + // Try extracting baseVal (standard SVG property) + if ('baseVal' in value && typeof value.baseVal === 'string') { + return value.baseVal; + } + // Try animVal as fallback + if ('animVal' in value && typeof value.animVal === 'string') { + return value.animVal; + } + // Fallback: Force to string (prevents WASM crash even if data is less useful) + // This prevents the "Invalid Type" crash, even if the data is "[object SVGAnimatedString]" + try { + return String(value); + } catch (e) { + return null; + } } + + // 3. Last resort cast for primitives try { return String(value); } catch (e) { @@ -128,13 +167,21 @@ function processSnapshotInBackground(rawData, options) { return new Promise((resolve, reject) => { const requestId = Math.random().toString(36).substring(7); + const TIMEOUT_MS = 25000; // 25 seconds (longer than content.js timeout) + let resolved = false; + const timeout = setTimeout(() => { - window.removeEventListener('message', listener); - reject(new Error('WASM processing timeout')); - }, 15000); // 15s timeout + if (!resolved) { + resolved = true; + window.removeEventListener('message', listener); + reject(new Error('WASM processing timeout - extension may be unresponsive. Try reloading the extension.')); + } + }, TIMEOUT_MS); const listener = (e) => { if (e.data.type === 'SENTIENCE_SNAPSHOT_RESULT' && e.data.requestId === requestId) { + if (resolved) return; // Already handled + resolved = true; clearTimeout(timeout); window.removeEventListener('message', listener); @@ -151,12 +198,22 @@ }; window.addEventListener('message', listener); - window.postMessage({ - type: 'SENTIENCE_SNAPSHOT_REQUEST', - requestId, - rawData, - options - }, '*'); + + try { + window.postMessage({ + type: 'SENTIENCE_SNAPSHOT_REQUEST', + requestId, + rawData, + options + }, '*'); + } catch (error) { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + window.removeEventListener('message', listener); + reject(new Error(`Failed to send snapshot request: ${error.message}`)); + } + } }); } @@ -343,6 +400,98 @@ return obj; } + // --- HELPER: Extract Raw Element Data (for Golden Set) --- + function extractRawElementData(el) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + return { + tag: el.tagName, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height) + }, + styles: { + cursor: style.cursor || null, + backgroundColor: style.backgroundColor || null, + color: style.color || null, + fontWeight: style.fontWeight || null, + fontSize: style.fontSize || null, + display: style.display || null, + position: style.position || null, + zIndex: style.zIndex || null, + opacity: style.opacity || null, + visibility: style.visibility || null + }, + attributes: { + role: el.getAttribute('role') || null, + type: el.getAttribute('type') || null, + ariaLabel: el.getAttribute('aria-label') || null, + id: el.id || null, + className: el.className || null + } + }; + } + + // --- HELPER: Generate Unique CSS Selector (for Golden Set) --- + function getUniqueSelector(el) { + if (!el || !el.tagName) return ''; + + // If element has a unique ID, use it + if (el.id) { + return `#${el.id}`; + } + + // Try data attributes or aria-label for uniqueness + for (const attr of el.attributes) { + if (attr.name.startsWith('data-') || attr.name === 'aria-label') { + const value = attr.value ? attr.value.replace(/"/g, '\\"') : ''; + return `${el.tagName.toLowerCase()}[${attr.name}="${value}"]`; + } + } + + // Build path with classes and nth-child for uniqueness + const path = []; + let current = el; + + while (current && current !== document.body && current !== document.documentElement) { + let selector = current.tagName.toLowerCase(); + + // If current element has ID, use it and stop + if (current.id) { + selector = `#${current.id}`; + path.unshift(selector); + break; + } + + // Add class if available + if (current.className && typeof current.className === 'string') { + const classes = current.className.trim().split(/\s+/).filter(c => c); + if (classes.length > 0) { + // Use first class for simplicity + selector += `.${classes[0]}`; + } + } + + // Add nth-of-type if needed for uniqueness + if (current.parentElement) { + const siblings = Array.from(current.parentElement.children); + const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName); + const index = sameTagSiblings.indexOf(current); + if (index > 0 || sameTagSiblings.length > 1) { + selector += `:nth-of-type(${index + 1})`; + } + } + + path.unshift(selector); + current = current.parentElement; + } + + return path.join(' > ') || el.tagName.toLowerCase(); + } + // --- GLOBAL API --- window.sentience = { // 1. Geometry snapshot (NEW ARCHITECTURE - No WASM in Main World!) @@ -385,7 +534,7 @@ role: toSafeString(el.getAttribute('role')), type_: toSafeString(el.getAttribute('type')), aria_label: toSafeString(el.getAttribute('aria-label')), - href: toSafeString(el.href), + href: toSafeString(el.href || el.getAttribute('href') || null), class: toSafeString(getClassName(el)) }, text: toSafeString(textVal), @@ -458,6 +607,198 @@ return true; } return false; + }, + + // 4. Inspector Mode: Start Recording for Golden Set Collection + startRecording: (options = {}) => { + const { + highlightColor = '#ff0000', + successColor = '#00ff00', + autoDisableTimeout = 30 * 60 * 1000, // 30 minutes default + keyboardShortcut = 'Ctrl+Shift+I' + } = options; + + console.log("🔴 [Sentience] Recording Mode STARTED. Click an element to copy its Ground Truth JSON."); + console.log(` Press ${keyboardShortcut} or call stopRecording() to stop.`); + + // Validate registry is populated + if (!window.sentience_registry || window.sentience_registry.length === 0) { + console.warn("⚠️ Registry empty. Call `await window.sentience.snapshot()` first to populate registry."); + alert("Registry empty. Run `await window.sentience.snapshot()` first!"); + return () => {}; // Return no-op cleanup function + } + + // Create reverse mapping for O(1) lookup (fixes registry lookup bug) + window.sentience_registry_map = new Map(); + window.sentience_registry.forEach((el, idx) => { + if (el) window.sentience_registry_map.set(el, idx); + }); + + // Create highlight box overlay + let highlightBox = document.getElementById('sentience-highlight-box'); + if (!highlightBox) { + highlightBox = document.createElement('div'); + highlightBox.id = 'sentience-highlight-box'; + highlightBox.style.cssText = ` + position: fixed; + pointer-events: none; + z-index: 2147483647; + border: 2px solid ${highlightColor}; + background: rgba(255, 0, 0, 0.1); + display: none; + transition: all 0.1s ease; + box-sizing: border-box; + `; + document.body.appendChild(highlightBox); + } + + // Create visual indicator (red border on page when recording) + let recordingIndicator = document.getElementById('sentience-recording-indicator'); + if (!recordingIndicator) { + recordingIndicator = document.createElement('div'); + recordingIndicator.id = 'sentience-recording-indicator'; + recordingIndicator.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3px; + background: ${highlightColor}; + z-index: 2147483646; + pointer-events: none; + `; + document.body.appendChild(recordingIndicator); + } + recordingIndicator.style.display = 'block'; + + // Hover handler (visual feedback) + const mouseOverHandler = (e) => { + const el = e.target; + if (!el || el === highlightBox || el === recordingIndicator) return; + + const rect = el.getBoundingClientRect(); + highlightBox.style.display = 'block'; + highlightBox.style.top = (rect.top + window.scrollY) + 'px'; + highlightBox.style.left = (rect.left + window.scrollX) + 'px'; + highlightBox.style.width = rect.width + 'px'; + highlightBox.style.height = rect.height + 'px'; + }; + + // Click handler (capture ground truth data) + const clickHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + + const el = e.target; + if (!el || el === highlightBox || el === recordingIndicator) return; + + // Use Map for reliable O(1) lookup + const sentienceId = window.sentience_registry_map.get(el); + if (sentienceId === undefined) { + console.warn("⚠️ Element not found in Sentience Registry. Did you run snapshot() first?"); + alert("Element not in registry. Run `await window.sentience.snapshot()` first!"); + return; + } + + // Extract raw data (ground truth + raw signals, NOT model outputs) + const rawData = extractRawElementData(el); + const selector = getUniqueSelector(el); + const role = el.getAttribute('role') || el.tagName.toLowerCase(); + const text = getText(el); + + // Build golden set JSON (ground truth + raw signals only) + const snippet = { + task: `Interact with ${text.substring(0, 20)}${text.length > 20 ? '...' : ''}`, + url: window.location.href, + timestamp: new Date().toISOString(), + target_criteria: { + id: sentienceId, + selector: selector, + role: role, + text: text.substring(0, 50) + }, + debug_snapshot: rawData + }; + + // Copy to clipboard + const jsonString = JSON.stringify(snippet, null, 2); + navigator.clipboard.writeText(jsonString).then(() => { + console.log("✅ Copied Ground Truth to clipboard:", snippet); + + // Flash green to indicate success + highlightBox.style.border = `2px solid ${successColor}`; + highlightBox.style.background = 'rgba(0, 255, 0, 0.2)'; + setTimeout(() => { + highlightBox.style.border = `2px solid ${highlightColor}`; + highlightBox.style.background = 'rgba(255, 0, 0, 0.1)'; + }, 500); + }).catch(err => { + console.error("❌ Failed to copy to clipboard:", err); + alert("Failed to copy to clipboard. Check console for JSON."); + }); + }; + + // Auto-disable timeout + let timeoutId = null; + + // Cleanup function to stop recording (defined before use) + const stopRecording = () => { + document.removeEventListener('mouseover', mouseOverHandler, true); + document.removeEventListener('click', clickHandler, true); + document.removeEventListener('keydown', keyboardHandler, true); + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + if (highlightBox) { + highlightBox.style.display = 'none'; + } + + if (recordingIndicator) { + recordingIndicator.style.display = 'none'; + } + + // Clean up registry map (optional, but good practice) + if (window.sentience_registry_map) { + window.sentience_registry_map.clear(); + } + + // Remove global reference + if (window.sentience_stopRecording === stopRecording) { + delete window.sentience_stopRecording; + } + + console.log("⚪ [Sentience] Recording Mode STOPPED."); + }; + + // Keyboard shortcut handler (defined after stopRecording) + const keyboardHandler = (e) => { + // Ctrl+Shift+I or Cmd+Shift+I + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'I') { + e.preventDefault(); + stopRecording(); + } + }; + + // Attach event listeners (use capture phase to intercept early) + document.addEventListener('mouseover', mouseOverHandler, true); + document.addEventListener('click', clickHandler, true); + document.addEventListener('keydown', keyboardHandler, true); + + // Set up auto-disable timeout + if (autoDisableTimeout > 0) { + timeoutId = setTimeout(() => { + console.log("⏰ [Sentience] Recording Mode auto-disabled after timeout."); + stopRecording(); + }, autoDisableTimeout); + } + + // Store stop function globally for keyboard shortcut access + window.sentience_stopRecording = stopRecording; + + return stopRecording; } }; diff --git a/sentience/extension/manifest.json b/sentience/extension/manifest.json index 5227103..170c6c2 100644 --- a/sentience/extension/manifest.json +++ b/sentience/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Sentience Semantic Visual Grounding Extractor", - "version": "2.0.0", + "version": "2.0.1", "description": "Extract semantic visual grounding data from web pages", "permissions": ["activeTab", "scripting"], "host_permissions": [""], diff --git a/sentience/extension/pkg/sentience_core_bg.wasm b/sentience/extension/pkg/sentience_core_bg.wasm index 4f94996ec56d31ad14fbe9409a349776e5ae4e83..1b0df4b8d79a08532854a7c07bfbc352de3a6141 100644 GIT binary patch delta 986 zcmY+8YfO_@9L9OxQ>nI$6(^&1I$t&!v{r?{iUOLMOp&F1i`tW_Q?K_JB>W`)r(zv4`vtyUn7O^Q;*Uv@Cwg z=GNREamRLgCAxV>$uZQ4s?P>A+~k)6bGa|jf*XpW7~$)Tbr=yp8(j=PiHfrJWORzD z(5E`O_*7^CG0I=He3VF%P_n zwqA2_Y%8tG!2O^=V3Yhk%vs$r%1l{F`^od%Om;7(dckKlI6AKXnTuJ7Nd~< zU5hk2RE#2-vKEUh^tB)FP|%O}smP?ge(02Hs7_x1sdU~CyE%I4m09r`NJT4M@MHdT zzAd2gR=>(y14tr!0JEuDmFTib-x^4vrvZeg%k_rx<{8MK4g*PAL4rJCAP8Dk3Ku;I zVyP@Eg>ME%>AOm-pdYL70fs18g>^K@I8IlX-KJi1oYNfN zTj<>oGLS_@A>_bKkq}DZk*7kKWuq%$IB2U9ITl7z`qDMNJbm#>ef2l>HFc4?n$2!! bS6L={XPx%_@CxnUSP&>TPccLe66(>x5QzqgW-Uvs%%Tc z4`L#;O2=_N5t_$0M%N%Bjz)8!-e_-3=Oep=FZDrVQd?f`bDFA>5-m;R)6vccfAjGl zo7-`fZ>06OD^AcGY3Sku-&dhdWF2L|4W82)PGmaoY&CG5|KD1O>!Pr2)Q9cd-y6gb zZ|E&j`AZ_7^e)Gc$UC!ohPv&S{SluieXK(`AFkBI&;uR(tEV4{HdXQArzV<3!LuUp zozL_5k+DI(eJoFOK6g0L+%3`L#D4ktWR%C(zG=ARq<6g7Gpkc`>spB;?3vKi>IpAQ zamOgzi!3^wj`iq}-CmpndSrtS4Q?EyZHw@++^`t$0l&)F5`3spD1b654qzcY@}o>1 z3m_}iCE^DsPNkAuyea?7K@+2E`B+9L^Ra|>E6La2MO4F2^1DLhYSdDQH2Kdmw7y0? zr3g|>DHh1FQmk^&Rs%Ds+`xP)j%$|zo$5_>%ZmowRAQ=b#6TW}EX>9s`olm*!nD~` z&j$=OdC0_6`q{#adn zmm^zNR-j-K2I%W*ET?@nSb#1nuTj&kxNjuguT0zG(O0daD=tfgV=r^-bTtUCoV5;( zlXs@*@chl(Q&RM}25j{w1l9G~-DSHwW9xTY=j^#byE|{|f~~X6?NVR!T+lq&`a$Pa$l)-iW@J|v