@@ -22,6 +22,7 @@ public sealed partial class PromptReferenceResolver(
2222{
2323 private const int MaxDirectoryReferenceFiles = 20 ;
2424 private const int MaxDirectoryReferenceBytes = 200 * 1024 ;
25+ private const long MaxImageReferenceBytes = 8 * 1024 * 1024 ;
2526 private static readonly HashSet < string > ImageExtensions = new ( StringComparer . OrdinalIgnoreCase )
2627 {
2728 ".png" , ".jpg" , ".jpeg" , ".gif" , ".webp"
@@ -230,8 +231,35 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
230231
231232 if ( ImageExtensions . Contains ( Path . GetExtension ( resolvedFull ) ) )
232233 {
233- var bytes = await File . ReadAllBytesAsync ( resolvedFull , cancellationToken ) . ConfigureAwait ( false ) ;
234+ cancellationToken . ThrowIfCancellationRequested ( ) ;
235+ var fileInfo = new FileInfo ( resolvedFull ) ;
236+ if ( ! fileInfo . Exists )
237+ {
238+ throw new InvalidOperationException ( $ "Referenced path is missing or unreadable: '{ resolvedFull } '.") ;
239+ }
240+
234241 var mediaType = ResolveMediaType ( resolvedFull ) ;
242+ if ( fileInfo . Length > MaxImageReferenceBytes )
243+ {
244+ var omittedPlaceholder =
245+ $ "[Referenced image omitted: { display } exceeds { FormatBytes ( MaxImageReferenceBytes ) } ]" + Environment . NewLine
246+ + $ "[End referenced image: { display } ]";
247+ return (
248+ omittedPlaceholder ,
249+ new PromptReference (
250+ PromptReferenceKind . Image ,
251+ rawToken ,
252+ pathPart ,
253+ resolvedFull ,
254+ display ,
255+ outsideWorkspace ,
256+ omittedPlaceholder ,
257+ MediaType : mediaType ,
258+ IncludedEntryCount : 0 ) ,
259+ null ) ;
260+ }
261+
262+ var bytes = await File . ReadAllBytesAsync ( resolvedFull , cancellationToken ) . ConfigureAwait ( false ) ;
235263 var placeholder =
236264 $ "[Referenced image: { display } ({ mediaType } )]" + Environment . NewLine
237265 + $ "[End referenced image: { display } ]";
@@ -287,14 +315,9 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
287315 string display ,
288316 CancellationToken cancellationToken )
289317 {
290- var files = Directory . EnumerateFiles ( directoryPath , "*" , SearchOption . AllDirectories )
291- . Where ( static path => ! ShouldSkipPath ( path ) )
292- . OrderBy ( static path => path , StringComparer . OrdinalIgnoreCase )
293- . ToArray ( ) ;
294-
295318 var included = new List < ( string RelativePath , string Content ) > ( ) ;
296319 var totalBytes = 0 ;
297- foreach ( var file in files )
320+ foreach ( var file in EnumerateReferenceFiles ( directoryPath , cancellationToken ) )
298321 {
299322 cancellationToken . ThrowIfCancellationRequested ( ) ;
300323 if ( included . Count >= MaxDirectoryReferenceFiles )
@@ -307,12 +330,28 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
307330 continue ;
308331 }
309332
333+ long fileLength ;
334+ try
335+ {
336+ fileLength = new FileInfo ( file ) . Length ;
337+ }
338+ catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
339+ {
340+ continue ;
341+ }
342+
343+ var remainingBytes = MaxDirectoryReferenceBytes - totalBytes ;
344+ if ( fileLength > remainingBytes )
345+ {
346+ break ;
347+ }
348+
310349 string text ;
311350 try
312351 {
313352 text = await File . ReadAllTextAsync ( file , cancellationToken ) . ConfigureAwait ( false ) ;
314353 }
315- catch
354+ catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException or DecoderFallbackException )
316355 {
317356 continue ;
318357 }
@@ -356,10 +395,73 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
356395 return ( builder . ToString ( ) , included . Count ) ;
357396 }
358397
398+ private static IEnumerable < string > EnumerateReferenceFiles ( string directoryPath , CancellationToken cancellationToken )
399+ {
400+ var pending = new Stack < string > ( ) ;
401+ pending . Push ( directoryPath ) ;
402+
403+ while ( pending . Count > 0 )
404+ {
405+ cancellationToken . ThrowIfCancellationRequested ( ) ;
406+ var current = pending . Pop ( ) ;
407+ foreach ( var file in EnumerateSortedFiles ( current ) )
408+ {
409+ cancellationToken . ThrowIfCancellationRequested ( ) ;
410+ if ( ! ShouldSkipPath ( file ) )
411+ {
412+ yield return file ;
413+ }
414+ }
415+
416+ var directories = EnumerateSortedDirectories ( current ) ;
417+ for ( var i = directories . Length - 1 ; i >= 0 ; i -- )
418+ {
419+ cancellationToken . ThrowIfCancellationRequested ( ) ;
420+ if ( ! ShouldSkipPath ( directories [ i ] ) )
421+ {
422+ pending . Push ( directories [ i ] ) ;
423+ }
424+ }
425+ }
426+ }
427+
428+ private static string [ ] EnumerateSortedFiles ( string directoryPath )
429+ {
430+ try
431+ {
432+ return Directory . EnumerateFiles ( directoryPath , "*" , SearchOption . TopDirectoryOnly )
433+ . OrderBy ( static path => path , StringComparer . OrdinalIgnoreCase )
434+ . ToArray ( ) ;
435+ }
436+ catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
437+ {
438+ return [ ] ;
439+ }
440+ }
441+
442+ private static string [ ] EnumerateSortedDirectories ( string directoryPath )
443+ {
444+ try
445+ {
446+ return Directory . EnumerateDirectories ( directoryPath , "*" , SearchOption . TopDirectoryOnly )
447+ . OrderBy ( static path => path , StringComparer . OrdinalIgnoreCase )
448+ . ToArray ( ) ;
449+ }
450+ catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
451+ {
452+ return [ ] ;
453+ }
454+ }
455+
359456 private static bool ShouldSkipPath ( string path )
360457 => path . Split ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar )
361458 . Any ( static segment => segment is ".git" or ".sharpclaw" or "bin" or "obj" ) ;
362459
460+ private static string FormatBytes ( long bytes )
461+ => bytes >= 1024 * 1024
462+ ? $ "{ bytes / ( 1024 * 1024 ) } MiB"
463+ : $ "{ bytes / 1024 } KiB";
464+
363465 private static string ResolveMediaType ( string path )
364466 => Path . GetExtension ( path ) . ToLowerInvariant ( ) switch
365467 {
0 commit comments