Skip to content

Commit 55df0d4

Browse files
committed
feat(fs): 7-tier fuzzy matching engine with indent-aware replacement
Rewrite tryFuzzyMatch with research-backed strategies from Aider + Continue: Tier 1: Uniform indent detection — detect same-prefix offset, re-indent replacement (Aider) Tier 2: Whitespace-normalized — each line trimmed independently (existing, kept) Tier 3: Skip spurious leading blank line — retry after removing LLM-added blanks (Aider) Tier 4: Block anchor — first/last line anchors for 3+ line blocks (existing, kept) Tier 5: Ellipsis/dotdotdots — handle ... wildcards for unchanged sections (Aider) Tier 6: Levenshtein with bracket protection + distance-proportional threshold (Continue) Tier 7: Variable-length window ±10% for when LLM adds/removes lines (Aider) Also: - Re-indent replacement text when uniform indent offset detected - Quality gate: reject fuzzy match if replacement removes >50% of matched lines - Upgraded error feedback with full-block similarity search (findSimilarBlock)
1 parent ca8db21 commit 55df0d4

1 file changed

Lines changed: 227 additions & 46 deletions

File tree

electron/services/skills/builtin/filesystem-tools.ts

Lines changed: 227 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
592764
function 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

Comments
 (0)