xc-mcp
Version:
MCP server that wraps Xcode command-line tools for iOS/macOS development workflows
361 lines • 13.3 kB
JavaScript
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