feat(context): Introduce smart context system: analyzer, lazy loader, cache and README/docs improvements
This commit is contained in:
		
							
								
								
									
										285
									
								
								ts/context/context-cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								ts/context/context-cache.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,285 @@
 | 
			
		||||
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);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user