286 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			286 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import * as plugins from '../plugins.js';
 | 
						|
import * as fs from 'fs';
 | 
						|
import type { ICacheEntry, ICacheConfig } from './types.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<string, ICacheEntry> = new Map();
 | 
						|
  private config: Required<ICacheConfig>;
 | 
						|
  private cacheIndexPath: string;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Creates a new ContextCache
 | 
						|
   * @param projectRoot - Root directory of the project
 | 
						|
   * @param config - Cache configuration
 | 
						|
   */
 | 
						|
  constructor(projectRoot: string, config: Partial<ICacheConfig> = {}) {
 | 
						|
    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<void> {
 | 
						|
    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<ICacheEntry | null> {
 | 
						|
    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<void> {
 | 
						|
    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<void> {
 | 
						|
    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<boolean> {
 | 
						|
    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<void> {
 | 
						|
    this.cache.clear();
 | 
						|
    await this.persist();
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Clears specific cache entries
 | 
						|
   * @param filePaths - Array of file paths to clear
 | 
						|
   */
 | 
						|
  public async clearPaths(filePaths: string[]): Promise<void> {
 | 
						|
    for (const path of filePaths) {
 | 
						|
      this.cache.delete(path);
 | 
						|
    }
 | 
						|
    await this.persist();
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Cleans up expired and invalid cache entries
 | 
						|
   */
 | 
						|
  private async cleanup(): Promise<void> {
 | 
						|
    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<void> {
 | 
						|
    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<void> {
 | 
						|
    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);
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 |