UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

273 lines (272 loc) 7.04 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { EventEmitter } from "events"; import { logger } from "../monitoring/logger.js"; class ContextCache extends EventEmitter { cache = /* @__PURE__ */ new Map(); accessOrder = []; options; stats; currentSize = 0; constructor(options = {}) { super(); this.options = { maxSize: options.maxSize || 100 * 1024 * 1024, // 100MB default maxItems: options.maxItems || 1e4, defaultTTL: options.defaultTTL || 36e5, // 1 hour default enableStats: options.enableStats ?? true, onEvict: options.onEvict || (() => { }) }; this.stats = { hits: 0, misses: 0, evictions: 0, size: 0, itemCount: 0, hitRate: 0, avgAccessTime: 0 }; } /** * Get item from cache */ get(key) { const startTime = Date.now(); const entry = this.cache.get(key); if (!entry) { this.stats.misses++; this.updateHitRate(); return void 0; } if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) { this.delete(key); this.stats.misses++; this.updateHitRate(); return void 0; } entry.hits++; entry.lastAccessed = Date.now(); this.updateAccessOrder(key); this.stats.hits++; this.updateHitRate(); this.updateAvgAccessTime(Date.now() - startTime); return entry.value; } /** * Set item in cache */ set(key, value, options = {}) { const size = options.size || this.estimateSize(value); const ttl = options.ttl ?? this.options.defaultTTL; if (this.cache.size >= this.options.maxItems || this.currentSize + size > this.options.maxSize) { this.evict(size); } if (this.cache.has(key)) { this.delete(key); } const entry = { value, size, hits: 0, createdAt: Date.now(), lastAccessed: Date.now(), ttl }; this.cache.set(key, entry); this.accessOrder.push(key); this.currentSize += size; this.stats.size = this.currentSize; this.stats.itemCount = this.cache.size; this.emit("set", key, value); } /** * Delete item from cache */ delete(key) { const entry = this.cache.get(key); if (!entry) return false; this.cache.delete(key); this.currentSize -= entry.size; this.accessOrder = this.accessOrder.filter((k) => k !== key); this.stats.size = this.currentSize; this.stats.itemCount = this.cache.size; this.emit("delete", key); return true; } /** * Clear entire cache */ clear() { const oldSize = this.cache.size; this.cache.clear(); this.accessOrder = []; this.currentSize = 0; this.stats.size = 0; this.stats.itemCount = 0; this.stats.evictions += oldSize; this.emit("clear"); } /** * Check if key exists and is valid */ has(key) { const entry = this.cache.get(key); if (!entry) return false; if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) { this.delete(key); return false; } return true; } /** * Get cache statistics */ getStats() { return { ...this.stats }; } /** * Get cache size info */ getSize() { return { bytes: this.currentSize, items: this.cache.size, utilization: this.currentSize / this.options.maxSize }; } /** * Preload multiple items */ preload(entries) { const startTime = Date.now(); for (const entry of entries) { this.set(entry.key, entry.value, { ttl: entry.ttl, size: entry.size }); } logger.debug("Cache preload complete", { items: entries.length, duration: Date.now() - startTime, cacheSize: this.currentSize }); } /** * Get multiple items efficiently */ getMany(keys) { const results = /* @__PURE__ */ new Map(); for (const key of keys) { const value = this.get(key); if (value !== void 0) { results.set(key, value); } } return results; } /** * Warm cache with computed values */ async warmUp(keys, compute, options = {}) { const { parallel = true } = options; if (parallel) { const promises = keys.map(async (key) => { if (!this.has(key)) { const value = await compute(key); this.set(key, value, { ttl: options.ttl }); } }); await Promise.all(promises); } else { for (const key of keys) { if (!this.has(key)) { const value = await compute(key); this.set(key, value, { ttl: options.ttl }); } } } } /** * Get or compute value */ async getOrCompute(key, compute, options = {}) { const cached = this.get(key); if (cached !== void 0) { return cached; } const value = await compute(); this.set(key, value, options); return value; } // Private methods evict(requiredSize) { const startEvictions = this.stats.evictions; while ((this.cache.size >= this.options.maxItems || this.currentSize + requiredSize > this.options.maxSize) && this.accessOrder.length > 0) { const keyToEvict = this.accessOrder.shift(); const entry = this.cache.get(keyToEvict); if (entry) { this.cache.delete(keyToEvict); this.currentSize -= entry.size; this.stats.evictions++; this.options.onEvict(keyToEvict, entry); this.emit("evict", keyToEvict, entry); } } if (this.stats.evictions > startEvictions) { logger.debug("Cache eviction", { evicted: this.stats.evictions - startEvictions, currentSize: this.currentSize, requiredSize }); } } updateAccessOrder(key) { const index = this.accessOrder.indexOf(key); if (index > -1) { this.accessOrder.splice(index, 1); } this.accessOrder.push(key); } estimateSize(value) { if (typeof value === "string") { return value.length * 2; } if (typeof value === "object" && value !== null) { return JSON.stringify(value).length * 2; } return 8; } updateHitRate() { const total = this.stats.hits + this.stats.misses; this.stats.hitRate = total > 0 ? this.stats.hits / total : 0; } updateAvgAccessTime(time) { const alpha = 0.1; this.stats.avgAccessTime = this.stats.avgAccessTime * (1 - alpha) + time * alpha; } /** * Cleanup expired entries periodically */ startCleanup(intervalMs = 6e4) { return setInterval(() => { let cleaned = 0; for (const [key, entry] of this.cache.entries()) { if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) { this.delete(key); cleaned++; } } if (cleaned > 0) { logger.debug("Cache cleanup", { cleaned, remaining: this.cache.size }); } }, intervalMs); } } export { ContextCache };