@@ -523,27 +523,62 @@ function lineSimilarity(a: string, b: string): number {
523523 return 1 - levenshteinDistance ( a , b ) / maxLen
524524}
525525
526- function tryFuzzyMatch ( original : string , oldString : string ) : { matched : string ; strategy : string } | null {
527- const origLines = original . split ( '\n' )
528- const oldLines = oldString . split ( '\n' )
526+ const BRACKET_LINES = new Set ( [ '}' , '};' , ']);' , ');' , '});' , '})' , ']' , ')' ] )
529527
530- if ( oldLines . length > 1 && oldLines [ oldLines . length - 1 ] === '' ) {
531- oldLines . pop ( )
528+ function leadingWhitespace ( line : string ) : string {
529+ const match = line . match ( / ^ ( \s * ) / )
530+ return match ? match [ 1 ] : ''
531+ }
532+
533+ function matchUniformIndent ( origLines : string [ ] , oldLines : string [ ] ) : { index : number ; indentOffset : string } | null {
534+ const oldLinesLstripped = oldLines . map ( l => l . trimStart ( ) )
535+
536+ for ( let i = 0 ; i <= origLines . length - oldLines . length ; i ++ ) {
537+ const window = origLines . slice ( i , i + oldLines . length )
538+ if ( ! window . every ( ( line , j ) => line . trimStart ( ) === oldLinesLstripped [ j ] ) ) continue
539+
540+ const offsets = new Set < string > ( )
541+ for ( let j = 0 ; j < oldLines . length ; j ++ ) {
542+ if ( ! oldLines [ j ] . trim ( ) ) continue
543+ const origLead = leadingWhitespace ( window [ j ] )
544+ const oldLead = leadingWhitespace ( oldLines [ j ] )
545+ if ( origLead . length < oldLead . length ) return null
546+ offsets . add ( origLead . slice ( 0 , origLead . length - oldLead . length ) )
547+ }
548+ if ( offsets . size === 1 ) {
549+ return { index : i , indentOffset : [ ...offsets ] [ 0 ] }
550+ }
532551 }
552+ return null
553+ }
533554
555+ interface FuzzyMatchResult {
556+ matched : string
557+ strategy : string
558+ indentOffset ?: string
559+ }
560+
561+ function tryFuzzyMatchCore ( origLines : string [ ] , oldLines : string [ ] ) : FuzzyMatchResult | null {
534562 if ( oldLines . length === 0 ) return null
535563
536564 const oldLinesTrimmed = oldLines . map ( l => l . trim ( ) )
537565
538- // Tier 1: Whitespace-normalized line matching
566+ // Tier 1: Uniform indent detection — detect ALL lines differ by same prefix, enables re-indentation
567+ const uniformResult = matchUniformIndent ( origLines , oldLines )
568+ if ( uniformResult ) {
569+ const window = origLines . slice ( uniformResult . index , uniformResult . index + oldLines . length )
570+ return { matched : window . join ( '\n' ) , strategy : 'uniform-indent' , indentOffset : uniformResult . indentOffset }
571+ }
572+
573+ // Tier 2: Whitespace-normalized line matching — each line trimmed independently
539574 for ( let i = 0 ; i <= origLines . length - oldLines . length ; i ++ ) {
540575 const window = origLines . slice ( i , i + oldLines . length )
541576 if ( window . every ( ( line , j ) => line . trim ( ) === oldLinesTrimmed [ j ] ) ) {
542577 return { matched : window . join ( '\n' ) , strategy : 'whitespace-normalized' }
543578 }
544579 }
545580
546- // Tier 2 : Block anchor matching (3+ lines, first/last line anchors only)
581+ // Tier 4 : Block anchor matching (3+ lines, first/last line anchors only)
547582 if ( oldLines . length >= 3 ) {
548583 const firstAnchor = oldLinesTrimmed [ 0 ]
549584 const lastAnchor = oldLinesTrimmed [ oldLinesTrimmed . length - 1 ]
@@ -557,38 +592,175 @@ function tryFuzzyMatch(original: string, oldString: string): { matched: string;
557592 }
558593 }
559594
560- // Tier 3: Levenshtein line-similarity matching (per-line sim >= 0.8, avg >= 0.85)
561- const LINE_THRESHOLD = 0.8
562- const AVG_THRESHOLD = 0.85
595+ // Tier 5: Ellipsis/dotdotdots — LLM uses "..." to mean "keep unchanged lines"
596+ const dotsResult = tryDotDotDots ( origLines . join ( '\n' ) , oldLines . join ( '\n' ) )
597+ if ( dotsResult ) {
598+ return dotsResult
599+ }
600+
601+ // Tier 6: Levenshtein with bracket protection + distance-proportional threshold
602+ const levenResult = levenshteinWindowMatch ( origLines , oldLines , oldLinesTrimmed , false )
603+ if ( levenResult ) return levenResult
604+
605+ // Tier 7: Variable-length window Levenshtein (±10% block size)
606+ const varResult = levenshteinWindowMatch ( origLines , oldLines , oldLinesTrimmed , true )
607+ if ( varResult ) return varResult
608+
609+ return null
610+ }
611+
612+ function tryDotDotDots ( whole : string , part : string ) : FuzzyMatchResult | null {
613+ const dotsRe = / ( ^ \s * \. { 3 , } \s * $ ) / m
614+ const pieces = part . split ( dotsRe ) . filter ( p => ! dotsRe . test ( p ) )
615+ if ( pieces . length < 2 ) return null
616+
617+ for ( const piece of pieces ) {
618+ if ( piece . trim ( ) && ! whole . includes ( piece . trim ( ) ) ) {
619+ const wholeLines = whole . split ( '\n' )
620+ const pieceLines = piece . trim ( ) . split ( '\n' )
621+ let found = false
622+ for ( let i = 0 ; i <= wholeLines . length - pieceLines . length ; i ++ ) {
623+ if ( wholeLines . slice ( i , i + pieceLines . length ) . every ( ( l , j ) => l . trim ( ) === pieceLines [ j ] . trim ( ) ) ) {
624+ found = true
625+ break
626+ }
627+ }
628+ if ( ! found ) return null
629+ }
630+ }
631+
632+ const firstPiece = pieces [ 0 ] . trim ( )
633+ const lastPiece = pieces [ pieces . length - 1 ] . trim ( )
634+ if ( ! firstPiece || ! lastPiece ) return null
635+
636+ const wholeLines = whole . split ( '\n' )
637+ const firstLines = firstPiece . split ( '\n' )
638+ const lastLines = lastPiece . split ( '\n' )
639+
640+ let startIdx = - 1
641+ for ( let i = 0 ; i <= wholeLines . length - firstLines . length ; i ++ ) {
642+ if ( wholeLines . slice ( i , i + firstLines . length ) . every ( ( l , j ) => l . trim ( ) === firstLines [ j ] . trim ( ) ) ) {
643+ startIdx = i
644+ break
645+ }
646+ }
647+ if ( startIdx === - 1 ) return null
648+
649+ let endIdx = - 1
650+ for ( let i = startIdx + firstLines . length ; i <= wholeLines . length - lastLines . length ; i ++ ) {
651+ if ( wholeLines . slice ( i , i + lastLines . length ) . every ( ( l , j ) => l . trim ( ) === lastLines [ j ] . trim ( ) ) ) {
652+ endIdx = i + lastLines . length
653+ break
654+ }
655+ }
656+ if ( endIdx === - 1 ) return null
657+
658+ const matched = wholeLines . slice ( startIdx , endIdx ) . join ( '\n' )
659+ return { matched, strategy : 'ellipsis' }
660+ }
661+
662+ function levenshteinWindowMatch (
663+ origLines : string [ ] , oldLines : string [ ] , oldLinesTrimmed : string [ ] , variableLength : boolean
664+ ) : FuzzyMatchResult | null {
665+ const baseLen = oldLines . length
666+ const minLen = variableLength ? Math . floor ( baseLen * 0.9 ) : baseLen
667+ const maxLen = variableLength ? Math . ceil ( baseLen * 1.1 ) : baseLen
668+
563669 let bestWindow : string [ ] | null = null
564670 let bestAvg = 0
565671
566- for ( let i = 0 ; i <= origLines . length - oldLines . length ; i ++ ) {
567- const window = origLines . slice ( i , i + oldLines . length )
568- let allPass = true
569- let totalSim = 0
672+ for ( let len = minLen ; len <= maxLen ; len ++ ) {
673+ for ( let i = 0 ; i <= origLines . length - len ; i ++ ) {
674+ const window = origLines . slice ( i , i + len )
675+ let allPass = true
676+ let totalSim = 0
677+
678+ const compareLen = Math . min ( len , oldLinesTrimmed . length )
679+ for ( let j = 0 ; j < compareLen ; j ++ ) {
680+ const origTrimmed = window [ j ] . trim ( )
681+ const searchTrimmed = oldLinesTrimmed [ j ] ?? ''
682+
683+ if ( BRACKET_LINES . has ( origTrimmed ) || BRACKET_LINES . has ( searchTrimmed ) ) {
684+ if ( origTrimmed !== searchTrimmed ) { allPass = false ; break }
685+ totalSim += 1.0
686+ continue
687+ }
570688
571- for ( let j = 0 ; j < oldLines . length ; j ++ ) {
572- const sim = lineSimilarity ( window [ j ] . trim ( ) , oldLinesTrimmed [ j ] )
573- if ( sim < LINE_THRESHOLD ) { allPass = false ; break }
574- totalSim += sim
575- }
689+ const threshold = Math . max ( 0 , 0.48 - j * 0.04 )
690+ const sim = lineSimilarity ( origTrimmed , searchTrimmed )
691+ if ( sim < threshold ) { allPass = false ; break }
692+ totalSim += sim
693+ }
576694
577- if ( ! allPass ) continue
578- const avg = totalSim / oldLines . length
579- if ( avg >= AVG_THRESHOLD && avg > bestAvg ) {
580- bestAvg = avg
581- bestWindow = window
695+ if ( ! allPass ) continue
696+ const avg = totalSim / compareLen
697+ if ( avg >= 0.75 && avg > bestAvg ) {
698+ bestAvg = avg
699+ bestWindow = window
700+ }
582701 }
583702 }
584703
585704 if ( bestWindow ) {
586- return { matched : bestWindow . join ( '\n' ) , strategy : 'levenshtein-similarity' }
705+ return {
706+ matched : bestWindow . join ( '\n' ) ,
707+ strategy : variableLength ? 'variable-window-levenshtein' : 'levenshtein-similarity'
708+ }
709+ }
710+ return null
711+ }
712+
713+ function tryFuzzyMatch ( original : string , oldString : string ) : FuzzyMatchResult | null {
714+ const origLines = original . split ( '\n' )
715+ const oldLines = oldString . split ( '\n' )
716+
717+ if ( oldLines . length > 1 && oldLines [ oldLines . length - 1 ] === '' ) {
718+ oldLines . pop ( )
719+ }
720+
721+ const result = tryFuzzyMatchCore ( origLines , oldLines )
722+ if ( result ) return result
723+
724+ // Tier 3: Skip spurious leading blank line — LLMs often add blank lines at start
725+ if ( oldLines . length > 1 && ! oldLines [ 0 ] . trim ( ) ) {
726+ const withoutBlank = oldLines . slice ( 1 )
727+ const retryResult = tryFuzzyMatchCore ( origLines , withoutBlank )
728+ if ( retryResult ) {
729+ retryResult . strategy += '+skip-blank'
730+ return retryResult
731+ }
587732 }
588733
589734 return null
590735}
591736
737+ function findSimilarBlock ( origLines : string [ ] , searchLines : string [ ] ) : string | null {
738+ if ( searchLines . length === 0 ) return null
739+ const searchTrimmed = searchLines . map ( l => l . trim ( ) )
740+ let bestRatio = 0
741+ let bestIdx = - 1
742+
743+ for ( let i = 0 ; i <= origLines . length - searchLines . length ; i ++ ) {
744+ const window = origLines . slice ( i , i + searchLines . length )
745+ let matchCount = 0
746+ for ( let j = 0 ; j < searchLines . length ; j ++ ) {
747+ if ( window [ j ] . trim ( ) === searchTrimmed [ j ] ) matchCount ++
748+ else if ( lineSimilarity ( window [ j ] . trim ( ) , searchTrimmed [ j ] ) > 0.6 ) matchCount += 0.5
749+ }
750+ const ratio = matchCount / searchLines . length
751+ if ( ratio > bestRatio ) {
752+ bestRatio = ratio
753+ bestIdx = i
754+ }
755+ }
756+
757+ if ( bestRatio < 0.3 || bestIdx === - 1 ) return null
758+ const contextStart = Math . max ( 0 , bestIdx - 3 )
759+ const contextEnd = Math . min ( origLines . length , bestIdx + searchLines . length + 3 )
760+ const lines = origLines . slice ( contextStart , contextEnd )
761+ return lines . map ( ( l , j ) => ` Line ${ contextStart + j + 1 } : ${ l . slice ( 0 , 120 ) } ` ) . join ( '\n' )
762+ }
763+
592764function toolEditFile ( repoPaths : string [ ] , args : { path : string ; old_string : string ; new_string : string } ) : { content : string ; isError : boolean } {
593765 try {
594766 const absPath = resolveSafePath ( repoPaths , args . path )
@@ -614,34 +786,43 @@ function toolEditFile(repoPaths: string[], args: { path: string; old_string: str
614786
615787 const fuzzy = tryFuzzyMatch ( original , args . old_string )
616788 if ( fuzzy ) {
617- const count = countOccurrences ( original , fuzzy . matched )
618- const updated = original . split ( fuzzy . matched ) . join ( args . new_string )
619- writeFileSync ( absPath , updated , 'utf-8' )
620- console . log ( `[FilesystemTools] Edited file (${ fuzzy . strategy } ): ${ args . path } (${ count } replacement${ count > 1 ? 's' : '' } )` )
621- return {
622- content : `Successfully replaced ${ count } occurrence${ count > 1 ? 's' : '' } in ${ args . path } (matched via ${ fuzzy . strategy } )` ,
623- isError : false
789+ let replacement = args . new_string
790+ if ( fuzzy . indentOffset ) {
791+ replacement = args . new_string . split ( '\n' ) . map ( line =>
792+ line . trim ( ) ? fuzzy . indentOffset + line : line
793+ ) . join ( '\n' )
624794 }
625- }
626795
627- const oldLines = args . old_string . split ( '\n' )
628- const firstLine = oldLines [ 0 ] . trim ( )
629- const lastLine = oldLines [ oldLines . length - 1 ] . trim ( )
630- const origLines = original . split ( '\n' )
631- const hints : string [ ] = [ ]
632- for ( let i = 0 ; i < origLines . length ; i ++ ) {
633- if ( origLines [ i ] . trim ( ) . includes ( firstLine . slice ( 0 , 40 ) ) ) {
634- hints . push ( ` Line ${ i + 1 } : ${ origLines [ i ] . trim ( ) . slice ( 0 , 80 ) } ` )
635- if ( hints . length >= 3 ) break
796+ const matchedLines = fuzzy . matched . split ( '\n' ) . length
797+ const replacementLines = replacement . split ( '\n' ) . length
798+ if ( replacementLines < matchedLines * 0.5 && matchedLines > 4 ) {
799+ console . log ( `[FilesystemTools] Quality gate: rejected fuzzy match (${ fuzzy . strategy } ) — would remove >${ Math . round ( ( 1 - replacementLines / matchedLines ) * 100 ) } % of matched lines` )
800+ } else {
801+ const count = countOccurrences ( original , fuzzy . matched )
802+ const updated = original . split ( fuzzy . matched ) . join ( replacement )
803+ writeFileSync ( absPath , updated , 'utf-8' )
804+ const indentNote = fuzzy . indentOffset ? ', re-indented' : ''
805+ console . log ( `[FilesystemTools] Edited file (${ fuzzy . strategy } ${ indentNote } ): ${ args . path } (${ count } replacement${ count > 1 ? 's' : '' } )` )
806+ return {
807+ content : `Successfully replaced ${ count } occurrence${ count > 1 ? 's' : '' } in ${ args . path } (matched via ${ fuzzy . strategy } ${ indentNote } )` ,
808+ isError : false
809+ }
636810 }
637811 }
638812
639- const hintText = hints . length > 0
640- ? `\n\nPossible matches found near:\n${ hints . join ( '\n' ) } \n\nTip: Use cortex_read_file with offset+limit to see exact content around these lines, then retry with the exact text.`
641- : `\n\nThe first line of your search ("${ firstLine . slice ( 0 , 60 ) } ") was not found anywhere in the file.`
813+ const origLines = original . split ( '\n' )
814+ const searchLines = args . old_string . split ( '\n' )
815+ const similarBlock = findSimilarBlock ( origLines , searchLines )
816+ const firstLine = searchLines [ 0 ] . trim ( )
817+
818+ const hintText = similarBlock
819+ ? `\n\nMost similar block found:\n${ similarBlock } \n\nTip: Use cortex_read_file with offset+limit to see exact content, then retry with the exact text.`
820+ : firstLine
821+ ? `\n\nThe first line of your search ("${ firstLine . slice ( 0 , 60 ) } ") was not found anywhere in the file.`
822+ : ''
642823
643824 return {
644- content : `old_string not found in "${ args . path } ". Tried: exact match → whitespace-normalized → block-anchor → levenshtein-similarity → all failed.${ hintText } ` ,
825+ content : `old_string not found in "${ args . path } ". Tried: exact → uniform-indent → whitespace-normalized → skip-blank → block-anchor → ellipsis → levenshtein → variable-window → all failed.${ hintText } ` ,
645826 isError : true
646827 }
647828 } catch ( err ) {
0 commit comments