@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
JavaScript
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