UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

434 lines (433 loc) 12.6 kB
/** * Tool Cache - Caches tool results and server responses * * Provides intelligent caching for MCP tool calls to improve performance * and reduce redundant operations. Supports multiple eviction strategies: * - LRU (Least Recently Used) * - FIFO (First In, First Out) * - LFU (Least Frequently Used) */ import { createHash } from "crypto"; import { EventEmitter } from "events"; import { withTimeout } from "../../utils/async/withTimeout.js"; /** * Tool Cache - High-performance caching for MCP tool results * * @example * ```typescript * const cache = new ToolCache({ * ttl: 60000, // 1 minute * maxSize: 500, * strategy: 'lru', * }); * * // Cache a tool result * cache.set('getUserById:123', { id: 123, name: 'John' }); * * // Retrieve from cache * const user = cache.get('getUserById:123'); * * // Invalidate by pattern * cache.invalidate('getUserById:*'); * ``` */ export class ToolCache extends EventEmitter { cache = new Map(); config; stats; cleanupTimer; constructor(config) { super(); this.config = { ttl: config.ttl, maxSize: config.maxSize, strategy: config.strategy, enableAutoCleanup: config.enableAutoCleanup ?? true, cleanupInterval: config.cleanupInterval ?? 60000, namespace: config.namespace ?? "", }; this.stats = { hits: 0, misses: 0, evictions: 0, size: 0, maxSize: this.config.maxSize, hitRate: 0, }; if (this.config.enableAutoCleanup) { this.startAutoCleanup(); } } /** * Get a value from the cache */ get(key) { const fullKey = this.getFullKey(key); const entry = this.cache.get(fullKey); if (!entry) { this.stats.misses++; this.updateHitRate(); this.emit("miss", { key: fullKey }); return undefined; } // Check expiration if (this.isExpired(entry)) { this.deleteWithReason(fullKey, "expired"); this.stats.misses++; this.updateHitRate(); this.emit("miss", { key: fullKey }); return undefined; } // Update access metadata entry.accessedAt = Date.now(); entry.accessCount++; this.stats.hits++; this.updateHitRate(); this.emit("hit", { key: fullKey, value: entry.value }); return entry.value; } /** * Set a value in the cache */ set(key, value, ttl) { const fullKey = this.getFullKey(key); const effectiveTtl = ttl ?? this.config.ttl; const now = Date.now(); // Evict if at capacity if (this.cache.size >= this.config.maxSize && !this.cache.has(fullKey)) { this.evictOne(); } const entry = { value, expires: now + effectiveTtl, createdAt: now, accessedAt: now, accessCount: 1, key: fullKey, }; this.cache.set(fullKey, entry); this.stats.size = this.cache.size; this.emit("set", { key: fullKey, value, ttl: effectiveTtl }); } /** * Check if a key exists and is not expired */ has(key) { const fullKey = this.getFullKey(key); const entry = this.cache.get(fullKey); if (!entry) { return false; } if (this.isExpired(entry)) { this.deleteWithReason(fullKey, "expired"); return false; } return true; } /** * Delete a specific key from the cache */ delete(key) { const fullKey = this.getFullKey(key); const deleted = this.cache.delete(fullKey); if (deleted) { this.stats.size = this.cache.size; this.emit("evict", { key: fullKey, reason: "manual" }); } return deleted; } /** * Invalidate entries matching a pattern * Supports glob-style patterns with * wildcard */ invalidate(pattern) { const fullPattern = this.getFullKey(pattern); const regex = this.patternToRegex(fullPattern); let invalidated = 0; for (const key of this.cache.keys()) { if (regex.test(key)) { this.cache.delete(key); invalidated++; this.emit("evict", { key, reason: "manual" }); } } this.stats.size = this.cache.size; return invalidated; } /** * Clear all entries from the cache */ clear() { const entriesRemoved = this.cache.size; this.cache.clear(); this.stats.size = 0; this.emit("clear", { entriesRemoved }); } /** * Get or set a value (cache-aside pattern) */ async getOrSet(key, factory, ttl) { const existing = this.get(key); if (existing !== undefined) { return existing; } const factoryTimeoutMs = 30_000; const value = await withTimeout(Promise.resolve(factory()), factoryTimeoutMs, `ToolCache getOrSet factory timed out after ${factoryTimeoutMs}ms for key "${key}"`); if (value === undefined) { return value; } this.set(key, value, ttl); return value; } /** * Get cache statistics */ getStats() { return { ...this.stats }; } /** * Reset statistics */ resetStats() { this.stats.hits = 0; this.stats.misses = 0; this.stats.evictions = 0; this.updateHitRate(); } /** * Get all keys in the cache */ keys() { return Array.from(this.cache.keys()); } /** * Get the number of entries in the cache */ get size() { return this.cache.size; } /** * Generate a cache key from tool name and arguments */ static generateKey(toolName, args) { const stableStringify = (value, seen = new WeakSet()) => { if (value === null || typeof value !== "object") { return JSON.stringify(value); } if (value instanceof Date) { return `{"$date":${JSON.stringify(value.toISOString())}}`; } if (seen.has(value)) { throw new TypeError("Circular structures are not supported in cache keys"); } seen.add(value); if (Array.isArray(value)) { const result = "[" + value.map((v) => stableStringify(v, seen)).join(",") + "]"; seen.delete(value); return result; } const sortedKeys = Object.keys(value).sort(); const entries = sortedKeys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k], seen)); seen.delete(value); return "{" + entries.join(",") + "}"; }; const argsHash = createHash("sha256") .update(stableStringify(args)) .digest("hex") .substring(0, 16); return `${toolName}:${argsHash}`; } /** * Stop the auto-cleanup timer */ destroy() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = undefined; } this.clear(); } // ==================== Private Methods ==================== getFullKey(key) { return this.config.namespace ? `${this.config.namespace}:${key}` : key; } isExpired(entry) { return Date.now() > entry.expires; } /** * Delete a cache entry by its full key with a specific eviction reason. */ deleteWithReason(fullKey, reason) { const deleted = this.cache.delete(fullKey); if (deleted) { this.stats.evictions++; this.stats.size = this.cache.size; this.emit("evict", { key: fullKey, reason }); } return deleted; } evictOne() { const entryToEvict = this.selectEvictionCandidate(); if (entryToEvict) { this.cache.delete(entryToEvict.key); this.stats.evictions++; this.stats.size = this.cache.size; this.emit("evict", { key: entryToEvict.key, reason: "capacity" }); } } selectEvictionCandidate() { if (this.cache.size === 0) { return undefined; } switch (this.config.strategy) { case "lru": return this.findLRU(); case "fifo": return this.findFIFO(); case "lfu": return this.findLFU(); default: return this.findLRU(); } } findLRU() { let oldest; let oldestTime = Infinity; for (const entry of this.cache.values()) { if (entry.accessedAt < oldestTime) { oldestTime = entry.accessedAt; oldest = entry; } } return oldest; } findFIFO() { let oldest; let oldestTime = Infinity; for (const entry of this.cache.values()) { if (entry.createdAt < oldestTime) { oldestTime = entry.createdAt; oldest = entry; } } return oldest; } findLFU() { let leastFrequent; let lowestCount = Infinity; for (const entry of this.cache.values()) { if (entry.accessCount < lowestCount) { lowestCount = entry.accessCount; leastFrequent = entry; } } return leastFrequent; } patternToRegex(pattern) { const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); const regexPattern = escaped.replace(/\*/g, "[^:]*"); return new RegExp(`^${regexPattern}$`); } updateHitRate() { const total = this.stats.hits + this.stats.misses; this.stats.hitRate = total > 0 ? this.stats.hits / total : 0; } startAutoCleanup() { this.cleanupTimer = setInterval(() => { this.cleanupExpired(); }, this.config.cleanupInterval); // Don't keep the process alive just for cleanup if (this.cleanupTimer.unref) { this.cleanupTimer.unref(); } } cleanupExpired() { const now = Date.now(); for (const [key, entry] of this.cache.entries()) { if (now > entry.expires) { this.cache.delete(key); this.stats.evictions++; this.emit("evict", { key, reason: "expired" }); } } this.stats.size = this.cache.size; } } /** * Factory function to create a ToolCache instance */ export const createToolCache = (config) => new ToolCache(config); /** * Default cache configuration */ export const DEFAULT_CACHE_CONFIG = { ttl: 5 * 60 * 1000, // 5 minutes maxSize: 500, strategy: "lru", enableAutoCleanup: true, cleanupInterval: 60000, // 1 minute }; /** * Tool-specific cache wrapper with automatic key generation */ export class ToolResultCache { cache; constructor(config) { this.cache = new ToolCache({ ...DEFAULT_CACHE_CONFIG, ...config, namespace: config?.namespace ?? "tool-results", }); } /** * Cache a tool result */ cacheResult(toolName, args, result, ttl) { const key = ToolCache.generateKey(toolName, args); this.cache.set(key, result, ttl); } /** * Get a cached tool result */ getCachedResult(toolName, args) { const key = ToolCache.generateKey(toolName, args); return this.cache.get(key); } /** * Check if a result is cached */ hasCachedResult(toolName, args) { const key = ToolCache.generateKey(toolName, args); return this.cache.has(key); } /** * Invalidate all cached results for a tool */ invalidateTool(toolName) { return this.cache.invalidate(`${toolName}:*`); } /** * Get cache statistics */ getStats() { return this.cache.getStats(); } /** * Clear all cached results */ clear() { this.cache.clear(); } /** * Destroy the cache */ destroy() { this.cache.destroy(); } } /** * Create a tool result cache instance */ export const createToolResultCache = (config) => new ToolResultCache(config);