UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

153 lines 5.16 kB
import { readFile, stat } from 'node:fs/promises'; import { CACHE_FILE_TTL_MS, MAX_FILE_READ_RETRIES } from '../constants.js'; /** * File content cache to reduce duplicate file reads during tool confirmation flow. * * The cache stores file content with mtime tracking to ensure data freshness. * Entries auto-expire after TTL_MS and are invalidated if file mtime changes. */ /** Maximum number of files to cache (exported for testing) */ export const MAX_CACHE_SIZE = 50; // Internal cache storage const cache = new Map(); let accessCounter = 0; // Track pending reads to deduplicate concurrent requests for the same file const pendingReads = new Map(); /** * Get file content from cache or read from disk. * Automatically checks mtime to ensure freshness. * Deduplicates concurrent requests for the same file. * * @param absPath - Absolute path to the file * @returns Cached file data with content, lines array, and mtime */ export async function getCachedFileContent(absPath) { const now = Date.now(); const entry = cache.get(absPath); if (entry) { const { data } = entry; // Check if cache entry has expired (TTL) if (now - data.cachedAt > CACHE_FILE_TTL_MS) { cache.delete(absPath); } else { // Check if file mtime has changed try { const fileStat = await stat(absPath); const currentMtime = fileStat.mtimeMs; if (currentMtime === data.mtime) { // Cache hit - update access order for LRU entry.accessOrder = ++accessCounter; return data; } // File was modified, invalidate cache and re-read cache.delete(absPath); // Check for pending read before starting a new one (deduplication) let pending = pendingReads.get(absPath); if (!pending) { // Reuse the stat we just did to avoid double stat pending = readAndCacheFile(absPath, now, fileStat.mtimeMs); pendingReads.set(absPath, pending); } try { return await pending; } finally { pendingReads.delete(absPath); } } catch { // File may have been deleted, invalidate cache cache.delete(absPath); } } } // Check if there's already a pending read for this file const pending = pendingReads.get(absPath); if (pending) { return pending; } // Cache miss - read from disk with deduplication const readPromise = readAndCacheFile(absPath, now); pendingReads.set(absPath, readPromise); try { return await readPromise; } finally { pendingReads.delete(absPath); } } /** * Read file from disk and cache it. * Verifies mtime didn't change during read to prevent race conditions. * Retries up to MAX_READ_RETRIES times if file changes during read. */ async function readAndCacheFile(absPath, now, knownMtime, retryCount = 0) { // Get mtime before reading (or use known mtime from caller) const mtimeBefore = knownMtime ?? (await stat(absPath)).mtimeMs; const content = await readFile(absPath, 'utf-8'); // Verify mtime didn't change during read const mtimeAfter = (await stat(absPath)).mtimeMs; if (mtimeAfter !== mtimeBefore) { if (retryCount >= MAX_FILE_READ_RETRIES) { throw new Error(`File ${absPath} is being modified too frequently, giving up after ${MAX_FILE_READ_RETRIES} retries`); } // File changed during read, retry with fresh timestamp return readAndCacheFile(absPath, Date.now(), undefined, retryCount + 1); } const cachedFile = { content, lines: content.split('\n'), mtime: mtimeAfter, cachedAt: now, }; // Enforce max cache size with LRU eviction if (cache.size >= MAX_CACHE_SIZE) { evictLRU(); } cache.set(absPath, { data: cachedFile, accessOrder: ++accessCounter, }); return cachedFile; } /** * Invalidate cache entry for a specific file. * Should be called after write operations complete. * * @param absPath - Absolute path to the file to invalidate */ export function invalidateCache(absPath) { cache.delete(absPath); } /** * Clear all cache entries. */ export function clearCache() { cache.clear(); pendingReads.clear(); accessCounter = 0; } /** * Get current cache size (for testing/debugging). */ export function getCacheSize() { return cache.size; } /** * Evict the least recently used entry from the cache. */ function evictLRU() { let oldestKey = null; let oldestOrder = Infinity; for (const [key, entry] of cache) { if (entry.accessOrder < oldestOrder) { oldestOrder = entry.accessOrder; oldestKey = key; } } if (oldestKey) { cache.delete(oldestKey); } } //# sourceMappingURL=file-cache.js.map