UNPKG

xc-mcp

Version:

MCP server that wraps Xcode command-line tools for iOS/macOS development workflows

361 lines 13.3 kB
import { generateCacheKey } from '../utils/view-fingerprinting.js'; import { persistenceManager } from '../utils/persistence.js'; /** * View Coordinate Cache * * Implements intelligent coordinate caching with: * - Element structure hash as primary key (per xcode-agent) * - Confidence tracking with auto-invalidation * - Auto-disable on low hit rate * - Conservative defaults for Phase 1 */ export class ViewCoordinateCache { static instance; cache = new Map(); config = { enabled: false, // Opt-in maxAge: 30 * 60 * 1000, // 30 minutes minConfidence: 0.8, // High bar maxCachedViews: 50, maxCoordinatesPerView: 5, autoDisableThreshold: 0.6, }; // Performance tracking (per xcode-agent recommendation) hitCount = 0; missCount = 0; totalQueries = 0; constructor() { // Load persisted state asynchronously this.loadPersistedState().catch(error => { console.warn('Failed to load view coordinate cache state:', error); }); } static getInstance() { if (!ViewCoordinateCache.instance) { ViewCoordinateCache.instance = new ViewCoordinateCache(); } return ViewCoordinateCache.instance; } // ============================================================================ // CONFIGURATION // ============================================================================ setConfig(config) { this.config = { ...this.config, ...config }; } getConfig() { return { ...this.config }; } enable() { this.config.enabled = true; } disable() { this.config.enabled = false; } isEnabled() { return this.config.enabled; } // ============================================================================ // CACHE OPERATIONS // ============================================================================ /** * Get cached coordinate for an element on a specific view */ async getCachedCoordinate(fingerprint, bundleId, elementId, appVersion) { if (!this.config.enabled) { return null; } this.totalQueries++; const cacheKey = generateCacheKey(fingerprint, bundleId, appVersion); const viewMapping = this.cache.get(cacheKey); if (!viewMapping) { this.missCount++; this.checkAutoDisable(); return null; } // Update last accessed viewMapping.lastAccessed = new Date(); const coordinate = viewMapping.coordinates.get(elementId); if (!coordinate) { this.missCount++; this.checkAutoDisable(); return null; } // Check age const age = Date.now() - coordinate.lastUsed.getTime(); if (age > this.config.maxAge) { // Entry expired - remove it viewMapping.coordinates.delete(elementId); this.missCount++; this.checkAutoDisable(); return null; } // Compute confidence with age decay const ageDecayFactor = Math.max(0, 1 - age / this.config.maxAge); const baseConfidence = coordinate.successCount / (coordinate.successCount + coordinate.failureCount); coordinate.confidence = baseConfidence * ageDecayFactor; // Check confidence threshold if (coordinate.confidence < this.config.minConfidence) { this.missCount++; this.checkAutoDisable(); return null; } // Cache hit! this.hitCount++; viewMapping.hitCount++; coordinate.lastUsed = new Date(); return coordinate; } /** * Store successful tap coordinates in cache */ async storeCoordinate(fingerprint, bundleId, elementId, elementType, x, y, bounds, appVersion) { if (!this.config.enabled) { return; } const cacheKey = generateCacheKey(fingerprint, bundleId, appVersion); // Get or create view mapping let viewMapping = this.cache.get(cacheKey); if (!viewMapping) { // Check cache size limit if (this.cache.size >= this.config.maxCachedViews) { this.evictLRU(); } viewMapping = { cacheKey, fingerprint, bundleId, appVersion, coordinates: new Map(), createdAt: new Date(), lastAccessed: new Date(), hitCount: 0, }; this.cache.set(cacheKey, viewMapping); } // Get or create coordinate entry let coordinate = viewMapping.coordinates.get(elementId); if (!coordinate) { // Check coordinates per view limit if (viewMapping.coordinates.size >= this.config.maxCoordinatesPerView) { // Remove least recently used coordinate this.evictLRUCoordinate(viewMapping); } coordinate = { elementId, elementType, x, y, bounds, confidence: 1.0, successCount: 1, failureCount: 0, createdAt: new Date(), lastUsed: new Date(), }; viewMapping.coordinates.set(elementId, coordinate); } else { // Update existing coordinate coordinate.x = x; coordinate.y = y; coordinate.bounds = bounds; coordinate.successCount++; coordinate.lastUsed = new Date(); coordinate.confidence = coordinate.successCount / (coordinate.successCount + coordinate.failureCount); } // Persist asynchronously await this.persistState(); } /** * Record successful tap using cached coordinate */ async recordSuccess(fingerprint, bundleId, elementId, appVersion) { const cacheKey = generateCacheKey(fingerprint, bundleId, appVersion); const viewMapping = this.cache.get(cacheKey); if (!viewMapping) return; const coordinate = viewMapping.coordinates.get(elementId); if (!coordinate) return; coordinate.successCount++; coordinate.lastUsed = new Date(); coordinate.confidence = coordinate.successCount / (coordinate.successCount + coordinate.failureCount); await this.persistState(); } /** * Invalidate coordinate on tap failure */ async invalidateCoordinate(fingerprint, bundleId, elementId, appVersion) { const cacheKey = generateCacheKey(fingerprint, bundleId, appVersion); const viewMapping = this.cache.get(cacheKey); if (!viewMapping) return; const coordinate = viewMapping.coordinates.get(elementId); if (!coordinate) return; coordinate.failureCount++; coordinate.confidence = coordinate.successCount / (coordinate.successCount + coordinate.failureCount); // Aggressive invalidation: remove if confidence drops below threshold if (coordinate.confidence < this.config.minConfidence) { viewMapping.coordinates.delete(elementId); console.error(`[view-coordinate-cache] Invalidated coordinate for ${elementId} (confidence: ${coordinate.confidence.toFixed(2)})`); } await this.persistState(); } /** * Clear entire cache */ clear() { this.cache.clear(); this.hitCount = 0; this.missCount = 0; this.totalQueries = 0; } /** * Clear cache for specific bundle ID */ clearForBundle(bundleId) { for (const [key, mapping] of this.cache.entries()) { if (mapping.bundleId === bundleId) { this.cache.delete(key); } } } // ============================================================================ // STATISTICS & OBSERVABILITY // ============================================================================ getStatistics() { const hitRate = this.totalQueries > 0 ? this.hitCount / this.totalQueries : 0; const missRate = this.totalQueries > 0 ? this.missCount / this.totalQueries : 0; let totalCoordinates = 0; for (const mapping of this.cache.values()) { totalCoordinates += mapping.coordinates.size; } return { enabled: this.config.enabled, hitRate, missRate, totalQueries: this.totalQueries, hitCount: this.hitCount, missCount: this.missCount, cachedViews: this.cache.size, totalCoordinates, config: this.config, }; } // ============================================================================ // INTERNAL HELPERS // ============================================================================ /** * Auto-disable cache if hit rate falls below threshold * Per xcode-agent recommendation */ checkAutoDisable() { if (this.totalQueries < 100) { return; // Need sufficient data } const hitRate = this.hitCount / this.totalQueries; if (hitRate < this.config.autoDisableThreshold) { console.warn(`[view-coordinate-cache] Hit rate ${(hitRate * 100).toFixed(1)}% < ${(this.config.autoDisableThreshold * 100).toFixed(1)}% threshold, auto-disabling cache`); this.config.enabled = false; } } /** * Evict least recently used view mapping (LRU eviction) */ evictLRU() { let oldestKey = null; let oldestTime = Date.now(); for (const [key, mapping] of this.cache.entries()) { if (mapping.lastAccessed.getTime() < oldestTime) { oldestTime = mapping.lastAccessed.getTime(); oldestKey = key; } } if (oldestKey) { this.cache.delete(oldestKey); } } /** * Evict least recently used coordinate from a view mapping */ evictLRUCoordinate(mapping) { let oldestElementId = null; let oldestTime = Date.now(); for (const [elementId, coordinate] of mapping.coordinates.entries()) { if (coordinate.lastUsed.getTime() < oldestTime) { oldestTime = coordinate.lastUsed.getTime(); oldestElementId = elementId; } } if (oldestElementId) { mapping.coordinates.delete(oldestElementId); } } // ============================================================================ // PERSISTENCE // ============================================================================ async persistState() { if (!persistenceManager.isEnabled()) { return; } try { // Convert Map to serializable object const serializable = { cache: Array.from(this.cache.entries()).map(([key, mapping]) => ({ key, mapping: { ...mapping, coordinates: Array.from(mapping.coordinates.entries()), }, })), hitCount: this.hitCount, missCount: this.missCount, totalQueries: this.totalQueries, }; await persistenceManager.saveState('view-coordinate-cache', serializable); } catch (error) { console.warn('[view-coordinate-cache] Failed to persist state:', error); } } async loadPersistedState() { if (!persistenceManager.isEnabled()) { return; } try { const data = await persistenceManager.loadState('view-coordinate-cache'); if (!data) { return; } const serialized = data; // Restore cache from serialized data this.cache.clear(); for (const { key, mapping } of serialized.cache || []) { this.cache.set(key, { ...mapping, coordinates: new Map(mapping.coordinates), createdAt: new Date(mapping.createdAt), lastAccessed: new Date(mapping.lastAccessed), fingerprint: { ...mapping.fingerprint, timestamp: new Date(mapping.fingerprint.timestamp), }, }); } this.hitCount = serialized.hitCount || 0; this.missCount = serialized.missCount || 0; this.totalQueries = serialized.totalQueries || 0; console.error(`[view-coordinate-cache] Loaded ${this.cache.size} cached views from persistence`); } catch (error) { console.warn('[view-coordinate-cache] Failed to load persisted state:', error); } } } // Export singleton instance export const viewCoordinateCache = ViewCoordinateCache.getInstance(); //# sourceMappingURL=view-coordinate-cache.js.map