/ utils / fileStateCache.ts
fileStateCache.ts
  1  import { LRUCache } from 'lru-cache'
  2  import { normalize } from 'path'
  3  
  4  export type FileState = {
  5    content: string
  6    timestamp: number
  7    offset: number | undefined
  8    limit: number | undefined
  9    // True when this entry was populated by auto-injection (e.g. CLAUDE.md) and
 10    // the injected content did not match disk (stripped HTML comments, stripped
 11    // frontmatter, truncated MEMORY.md). The model has only seen a partial view;
 12    // Edit/Write must require an explicit Read first. `content` here holds the
 13    // RAW disk bytes (for getChangedFiles diffing), not what the model saw.
 14    isPartialView?: boolean
 15  }
 16  
 17  // Default max entries for read file state caches
 18  export const READ_FILE_STATE_CACHE_SIZE = 100
 19  
 20  // Default size limit for file state caches (25MB)
 21  // This prevents unbounded memory growth from large file contents
 22  const DEFAULT_MAX_CACHE_SIZE_BYTES = 25 * 1024 * 1024
 23  
 24  /**
 25   * A file state cache that normalizes all path keys before access.
 26   * This ensures consistent cache hits regardless of whether callers pass
 27   * relative vs absolute paths with redundant segments (e.g. /foo/../bar)
 28   * or mixed path separators on Windows (/ vs \).
 29   */
 30  export class FileStateCache {
 31    private cache: LRUCache<string, FileState>
 32  
 33    constructor(maxEntries: number, maxSizeBytes: number) {
 34      this.cache = new LRUCache<string, FileState>({
 35        max: maxEntries,
 36        maxSize: maxSizeBytes,
 37        sizeCalculation: value => Math.max(1, Buffer.byteLength(value.content)),
 38      })
 39    }
 40  
 41    get(key: string): FileState | undefined {
 42      return this.cache.get(normalize(key))
 43    }
 44  
 45    set(key: string, value: FileState): this {
 46      this.cache.set(normalize(key), value)
 47      return this
 48    }
 49  
 50    has(key: string): boolean {
 51      return this.cache.has(normalize(key))
 52    }
 53  
 54    delete(key: string): boolean {
 55      return this.cache.delete(normalize(key))
 56    }
 57  
 58    clear(): void {
 59      this.cache.clear()
 60    }
 61  
 62    get size(): number {
 63      return this.cache.size
 64    }
 65  
 66    get max(): number {
 67      return this.cache.max
 68    }
 69  
 70    get maxSize(): number {
 71      return this.cache.maxSize
 72    }
 73  
 74    get calculatedSize(): number {
 75      return this.cache.calculatedSize
 76    }
 77  
 78    keys(): Generator<string> {
 79      return this.cache.keys()
 80    }
 81  
 82    entries(): Generator<[string, FileState]> {
 83      return this.cache.entries()
 84    }
 85  
 86    dump(): ReturnType<LRUCache<string, FileState>['dump']> {
 87      return this.cache.dump()
 88    }
 89  
 90    load(entries: ReturnType<LRUCache<string, FileState>['dump']>): void {
 91      this.cache.load(entries)
 92    }
 93  }
 94  
 95  /**
 96   * Factory function to create a size-limited FileStateCache.
 97   * Uses LRUCache's built-in size-based eviction to prevent memory bloat.
 98   * Note: Images are not cached (see FileReadTool) so size limit is mainly
 99   * for large text files, notebooks, and other editable content.
100   */
101  export function createFileStateCacheWithSizeLimit(
102    maxEntries: number,
103    maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES,
104  ): FileStateCache {
105    return new FileStateCache(maxEntries, maxSizeBytes)
106  }
107  
108  // Helper function to convert cache to object (used by compact.ts)
109  export function cacheToObject(
110    cache: FileStateCache,
111  ): Record<string, FileState> {
112    return Object.fromEntries(cache.entries())
113  }
114  
115  // Helper function to get all keys from cache (used by several components)
116  export function cacheKeys(cache: FileStateCache): string[] {
117    return Array.from(cache.keys())
118  }
119  
120  // Helper function to clone a FileStateCache
121  // Preserves size limit configuration from the source cache
122  export function cloneFileStateCache(cache: FileStateCache): FileStateCache {
123    const cloned = createFileStateCacheWithSizeLimit(cache.max, cache.maxSize)
124    cloned.load(cache.dump())
125    return cloned
126  }
127  
128  // Merge two file state caches, with more recent entries (by timestamp) overriding older ones
129  export function mergeFileStateCaches(
130    first: FileStateCache,
131    second: FileStateCache,
132  ): FileStateCache {
133    const merged = cloneFileStateCache(first)
134    for (const [filePath, fileState] of second.entries()) {
135      const existing = merged.get(filePath)
136      // Only override if the new entry is more recent
137      if (!existing || fileState.timestamp > existing.timestamp) {
138        merged.set(filePath, fileState)
139      }
140    }
141    return merged
142  }