1- import fs from "fs"
1+ import { stat } from "fs/promises "
22import { lruCache } from "../utils/lruCache"
33import { ROO_AGENT_CONFIG } from "../config/envConfig"
44
55const CACHE_SIZE = ROO_AGENT_CONFIG . fileReadCacheSize ( )
6+ const MAX_CACHE_MEMORY_MB = 100 // Maximum memory usage in MB for file content cache
7+ const MAX_CACHE_MEMORY_BYTES = MAX_CACHE_MEMORY_MB * 1024 * 1024
68
79// Types
810export interface LineRange {
@@ -32,8 +34,87 @@ type CacheResult =
3234 | { status : "ALLOW_PARTIAL" ; rangesToRead : LineRange [ ] }
3335 | { status : "REJECT_ALL" ; rangesToRead : LineRange [ ] }
3436
35- // Initialize a new LRU cache for file modification times.
36- const mtimeCache = new lruCache < string , string > ( CACHE_SIZE )
37+ // Cache entry with size tracking
38+ interface CacheEntry {
39+ mtime : string
40+ size : number // Size in bytes
41+ }
42+
43+ // Memory-aware cache for tracking file metadata
44+ class MemoryAwareCache {
45+ private cache : Map < string , CacheEntry > = new Map ( )
46+ private totalSize : number = 0
47+ private maxSize : number
48+
49+ constructor ( maxSizeBytes : number ) {
50+ this . maxSize = maxSizeBytes
51+ }
52+
53+ get ( key : string ) : string | undefined {
54+ const entry = this . cache . get ( key )
55+ if ( ! entry ) return undefined
56+
57+ // Move to end (most recently used)
58+ this . cache . delete ( key )
59+ this . cache . set ( key , entry )
60+ return entry . mtime
61+ }
62+
63+ set ( key : string , mtime : string , size : number ) : void {
64+ // Remove existing entry if present
65+ if ( this . cache . has ( key ) ) {
66+ const oldEntry = this . cache . get ( key ) !
67+ this . totalSize -= oldEntry . size
68+ this . cache . delete ( key )
69+ }
70+
71+ // Evict oldest entries if needed
72+ while ( this . totalSize + size > this . maxSize && this . cache . size > 0 ) {
73+ const oldestKey = this . cache . keys ( ) . next ( ) . value
74+ if ( oldestKey !== undefined ) {
75+ const oldestEntry = this . cache . get ( oldestKey ) !
76+ this . totalSize -= oldestEntry . size
77+ this . cache . delete ( oldestKey )
78+ console . log ( `[FileReadCache] Evicted ${ oldestKey } to free ${ oldestEntry . size } bytes` )
79+ }
80+ }
81+
82+ // Add new entry
83+ this . cache . set ( key , { mtime, size } )
84+ this . totalSize += size
85+ }
86+
87+ delete ( key : string ) : boolean {
88+ const entry = this . cache . get ( key )
89+ if ( ! entry ) return false
90+
91+ this . totalSize -= entry . size
92+ this . cache . delete ( key )
93+ return true
94+ }
95+
96+ clear ( ) : void {
97+ this . cache . clear ( )
98+ this . totalSize = 0
99+ }
100+
101+ getStats ( ) {
102+ return {
103+ entries : this . cache . size ,
104+ totalSizeBytes : this . totalSize ,
105+ totalSizeMB : ( this . totalSize / 1024 / 1024 ) . toFixed ( 2 ) ,
106+ maxSizeBytes : this . maxSize ,
107+ maxSizeMB : ( this . maxSize / 1024 / 1024 ) . toFixed ( 2 ) ,
108+ utilizationPercent : ( ( this . totalSize / this . maxSize ) * 100 ) . toFixed ( 1 ) ,
109+ }
110+ }
111+ }
112+
113+ // Initialize memory-aware cache
114+ const memoryAwareCache = new MemoryAwareCache ( MAX_CACHE_MEMORY_BYTES )
115+
116+ // Export as mtimeCache for compatibility with tests and existing code
117+ export const mtimeCache = memoryAwareCache
37118
38119/**
39120 * Checks if two line ranges overlap.
@@ -90,18 +171,37 @@ export function subtractRanges(originals: LineRange[], toRemoves: LineRange[]):
90171async function getFileMtime ( filePath : string ) : Promise < string | null > {
91172 const cachedMtime = mtimeCache . get ( filePath )
92173 if ( cachedMtime ) {
93- return cachedMtime
174+ try {
175+ const stats = await stat ( filePath )
176+ if ( stats . mtime . toISOString ( ) === cachedMtime ) {
177+ return cachedMtime
178+ }
179+ // Update cache with new mtime and size
180+ const mtime = stats . mtime . toISOString ( )
181+ mtimeCache . set ( filePath , mtime , stats . size )
182+ return mtime
183+ } catch ( error ) {
184+ if ( error instanceof Error && "code" in error && error . code === "ENOENT" ) {
185+ // File was deleted, remove from cache
186+ mtimeCache . delete ( filePath )
187+ return null
188+ }
189+ // For other errors like permission issues, log and rethrow
190+ console . error ( `[FileReadCache] Error checking file ${ filePath } :` , error )
191+ throw error
192+ }
94193 }
95194 try {
96- const stats = await fs . promises . stat ( filePath )
195+ const stats = await stat ( filePath )
97196 const mtime = stats . mtime . toISOString ( )
98- mtimeCache . set ( filePath , mtime )
197+ mtimeCache . set ( filePath , mtime , stats . size )
99198 return mtime
100199 } catch ( error ) {
101200 if ( error instanceof Error && "code" in error && error . code === "ENOENT" ) {
102201 return null // File does not exist, so no mtime.
103202 }
104203 // For other errors, we want to know about them.
204+ console . error ( `[FileReadCache] Error accessing file ${ filePath } :` , error )
105205 throw error
106206 }
107207}
@@ -119,7 +219,22 @@ export async function processAndFilterReadRequest(
119219 conversationHistory : ConversationMessage [ ] ,
120220) : Promise < CacheResult > {
121221 try {
122- const currentMtime = await getFileMtime ( requestedFilePath )
222+ // First attempt to get file mtime
223+ let currentMtime : string | null
224+ try {
225+ currentMtime = await getFileMtime ( requestedFilePath )
226+ } catch ( error ) {
227+ // Handle file system errors gracefully
228+ if ( error instanceof Error && "code" in error ) {
229+ const code = ( error as any ) . code
230+ if ( code === "EACCES" || code === "EPERM" ) {
231+ console . warn ( `[FileReadCache] Permission denied accessing ${ requestedFilePath } ` )
232+ return { status : "ALLOW_ALL" , rangesToRead : requestedRanges }
233+ }
234+ }
235+ throw error // Re-throw other unexpected errors
236+ }
237+
123238 if ( currentMtime === null ) {
124239 // If file does not exist, there's nothing to read from cache. Let the tool handle it.
125240 return { status : "ALLOW_ALL" , rangesToRead : requestedRanges }
@@ -137,30 +252,55 @@ export async function processAndFilterReadRequest(
137252 }
138253
139254 for ( const message of conversationHistory ) {
140- if ( message . files ) {
141- for ( const file of message . files ) {
142- if ( file . fileName === requestedFilePath && new Date ( file . mtime ) >= new Date ( currentMtime ) ) {
143- // File in history is up-to-date. Check ranges.
144- for ( const cachedRange of file . lineRanges ) {
145- rangesToRead = rangesToRead . flatMap ( ( reqRange ) => {
146- if ( rangesOverlap ( reqRange , cachedRange ) ) {
147- return subtractRange ( reqRange , cachedRange )
148- }
149- return [ reqRange ]
150- } )
151- }
255+ if ( ! message . files ?. length ) continue
256+ for ( const file of message . files ) {
257+ if ( file . fileName !== requestedFilePath ) continue
258+ // Normalise the mtime coming from the history because it could be
259+ // a number (ms since epoch) or already an ISO string.
260+ const fileMtimeMs = typeof file . mtime === "number" ? file . mtime : Date . parse ( String ( file . mtime ) )
261+ if ( Number . isNaN ( fileMtimeMs ) ) {
262+ // If the mtime cannot be parsed, skip this history entry – we cannot
263+ // rely on it for cache validation.
264+ continue
265+ }
266+ // Only treat the history entry as valid if it is at least as fresh as
267+ // the file on disk.
268+ if ( fileMtimeMs >= Date . parse ( currentMtime ) ) {
269+ // File in history is up-to-date. Check ranges.
270+ for ( const cachedRange of file . lineRanges ) {
271+ rangesToRead = rangesToRead . flatMap ( ( reqRange ) => {
272+ if ( rangesOverlap ( reqRange , cachedRange ) ) {
273+ return subtractRange ( reqRange , cachedRange )
274+ }
275+ return [ reqRange ]
276+ } )
152277 }
153278 }
154279 }
155280 }
156281
282+ // Decide the cache policy based on how the requested ranges compare to the
283+ // ranges that still need to be read after checking the conversation history.
157284 if ( rangesToRead . length === 0 ) {
285+ // The entire request is already satisfied by the cache.
158286 return { status : "REJECT_ALL" , rangesToRead : [ ] }
159- } else if ( rangesToRead . length < requestedRanges . length ) {
287+ }
288+
289+ // A partial hit occurs when *any* of the requested ranges were served by the
290+ // cache. Comparing only the array length is not sufficient because the number
291+ // of ranges can stay the same even though their boundaries have changed
292+ // (e.g. `[ {1-20} ]` -> `[ {11-20} ]`). Instead, detect partial hits by
293+ // checking deep equality with the original request.
294+ const isPartial =
295+ rangesToRead . length !== requestedRanges . length ||
296+ JSON . stringify ( rangesToRead ) !== JSON . stringify ( requestedRanges )
297+
298+ if ( isPartial ) {
160299 return { status : "ALLOW_PARTIAL" , rangesToRead }
161- } else {
162- return { status : "ALLOW_ALL" , rangesToRead : requestedRanges }
163300 }
301+
302+ // No overlap with cache – allow the full request through.
303+ return { status : "ALLOW_ALL" , rangesToRead : requestedRanges }
164304 } catch ( error ) {
165305 console . error ( `Error processing file read request for ${ requestedFilePath } :` , error )
166306 // On other errors, allow the read to proceed to let the tool handle it.
0 commit comments