import * as plugins from '../plugins.js'; import * as fs from 'fs'; import type { ICacheEntry, ICacheConfig } from './types.js'; import { logger } from '../logging.js'; /** * ContextCache provides persistent caching of file contents and token counts * with automatic invalidation on file changes */ export class ContextCache { private cacheDir: string; private cache: Map = new Map(); private config: Required; private cacheIndexPath: string; /** * Creates a new ContextCache * @param projectRoot - Root directory of the project * @param config - Cache configuration */ constructor(projectRoot: string, config: Partial = {}) { this.config = { enabled: config.enabled ?? true, ttl: config.ttl ?? 3600, // 1 hour default maxSize: config.maxSize ?? 100, // 100MB default directory: config.directory ?? plugins.path.join(projectRoot, '.nogit', 'context-cache'), }; this.cacheDir = this.config.directory; this.cacheIndexPath = plugins.path.join(this.cacheDir, 'index.json'); } /** * Initializes the cache by loading from disk */ public async init(): Promise { if (!this.config.enabled) { return; } // Ensure cache directory exists await plugins.smartfile.fs.ensureDir(this.cacheDir); // Load cache index if it exists try { const indexExists = await plugins.smartfile.fs.fileExists(this.cacheIndexPath); if (indexExists) { const indexContent = await plugins.smartfile.fs.toStringSync(this.cacheIndexPath); const indexData = JSON.parse(indexContent) as ICacheEntry[]; if (Array.isArray(indexData)) { for (const entry of indexData) { this.cache.set(entry.path, entry); } } } } catch (error) { console.warn('Failed to load cache index:', error.message); // Start with empty cache if loading fails } // Clean up expired and invalid entries await this.cleanup(); } /** * Gets a cached entry if it's still valid * @param filePath - Absolute path to the file * @returns Cache entry if valid, null otherwise */ public async get(filePath: string): Promise { if (!this.config.enabled) { return null; } const entry = this.cache.get(filePath); if (!entry) { return null; } // Check if entry is expired const now = Date.now(); if (now - entry.cachedAt > this.config.ttl * 1000) { this.cache.delete(filePath); return null; } // Check if file has been modified try { const stats = await fs.promises.stat(filePath); const currentMtime = Math.floor(stats.mtimeMs); if (currentMtime !== entry.mtime) { // File has changed, invalidate cache this.cache.delete(filePath); return null; } return entry; } catch (error) { // File doesn't exist anymore this.cache.delete(filePath); return null; } } /** * Stores a cache entry * @param entry - Cache entry to store */ public async set(entry: ICacheEntry): Promise { if (!this.config.enabled) { return; } this.cache.set(entry.path, entry); // Check cache size and evict old entries if needed await this.enforceMaxSize(); // Persist to disk (async, don't await) this.persist().catch((error) => { console.warn('Failed to persist cache:', error.message); }); } /** * Stores multiple cache entries * @param entries - Array of cache entries */ public async setMany(entries: ICacheEntry[]): Promise { if (!this.config.enabled) { return; } for (const entry of entries) { this.cache.set(entry.path, entry); } await this.enforceMaxSize(); await this.persist(); } /** * Checks if a file is cached and valid * @param filePath - Absolute path to the file * @returns True if cached and valid */ public async has(filePath: string): Promise { const entry = await this.get(filePath); return entry !== null; } /** * Gets cache statistics */ public getStats(): { entries: number; totalSize: number; oldestEntry: number | null; newestEntry: number | null; } { let totalSize = 0; let oldestEntry: number | null = null; let newestEntry: number | null = null; for (const entry of this.cache.values()) { totalSize += entry.contents.length; if (oldestEntry === null || entry.cachedAt < oldestEntry) { oldestEntry = entry.cachedAt; } if (newestEntry === null || entry.cachedAt > newestEntry) { newestEntry = entry.cachedAt; } } return { entries: this.cache.size, totalSize, oldestEntry, newestEntry, }; } /** * Clears all cache entries */ public async clear(): Promise { this.cache.clear(); await this.persist(); } /** * Clears specific cache entries * @param filePaths - Array of file paths to clear */ public async clearPaths(filePaths: string[]): Promise { for (const path of filePaths) { this.cache.delete(path); } await this.persist(); } /** * Cleans up expired and invalid cache entries */ private async cleanup(): Promise { const now = Date.now(); const toDelete: string[] = []; for (const [path, entry] of this.cache.entries()) { // Check expiration if (now - entry.cachedAt > this.config.ttl * 1000) { toDelete.push(path); continue; } // Check if file still exists and hasn't changed try { const stats = await fs.promises.stat(path); const currentMtime = Math.floor(stats.mtimeMs); if (currentMtime !== entry.mtime) { toDelete.push(path); } } catch (error) { // File doesn't exist toDelete.push(path); } } for (const path of toDelete) { this.cache.delete(path); } if (toDelete.length > 0) { await this.persist(); } } /** * Enforces maximum cache size by evicting oldest entries */ private async enforceMaxSize(): Promise { const stats = this.getStats(); const maxSizeBytes = this.config.maxSize * 1024 * 1024; // Convert MB to bytes if (stats.totalSize <= maxSizeBytes) { return; } // Sort entries by age (oldest first) const entries = Array.from(this.cache.entries()).sort( (a, b) => a[1].cachedAt - b[1].cachedAt ); // Remove oldest entries until we're under the limit let currentSize = stats.totalSize; for (const [path, entry] of entries) { if (currentSize <= maxSizeBytes) { break; } currentSize -= entry.contents.length; this.cache.delete(path); } } /** * Persists cache index to disk */ private async persist(): Promise { if (!this.config.enabled) { return; } try { const entries = Array.from(this.cache.values()); const content = JSON.stringify(entries, null, 2); await plugins.smartfile.memory.toFs(content, this.cacheIndexPath); } catch (error) { console.warn('Failed to persist cache index:', error.message); } } }