feat(port): ported pi-mono improvements + worker & patch improvements

- added Azure OpenAI Responses support and OpenRouter routing compat
- improved patch applicator diagnostics and subagent context propagation
- updated editor cursor handling, keybindings, and working message API
- refreshed porting sync metadata
This commit is contained in:
can1357
2026-01-25 13:50:08 +01:00
parent 3e2c731aa0
commit 7bb2e1aa58
44 changed files with 1656 additions and 176 deletions
+202 -38
View File
@@ -7,7 +7,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { resolveToCwd } from "../tools/path-utils";
import { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch, seekSequence } from "./fuzzy";
import { DEFAULT_FUZZY_THRESHOLD, findClosestSequenceMatch, findContextLine, findMatch, seekSequence } from "./fuzzy";
import {
adjustIndentation,
convertLeadingTabsToSpaces,
@@ -67,9 +67,12 @@ interface Replacement {
newLines: string[];
}
type HunkVariantKind = "trim-common" | "dedupe-shared" | "collapse-repeated" | "single-line";
interface HunkVariant {
oldLines: string[];
newLines: string[];
kind: HunkVariantKind;
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -272,7 +275,7 @@ function trimCommonContext(oldLines: string[], newLines: string[]): HunkVariant
if (trimmedOld.length === 0 && trimmedNew.length === 0) {
return undefined;
}
return { oldLines: trimmedOld, newLines: trimmedNew };
return { oldLines: trimmedOld, newLines: trimmedNew, kind: "trim-common" };
}
function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]): HunkVariant | undefined {
@@ -297,7 +300,7 @@ function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]):
if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) {
return undefined;
}
return { oldLines: collapsedOld, newLines: collapsedNew };
return { oldLines: collapsedOld, newLines: collapsedNew, kind: "dedupe-shared" };
}
function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVariant | undefined {
@@ -339,7 +342,7 @@ function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVar
if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) {
return undefined;
}
return { oldLines: collapsedOld, newLines: collapsedNew };
return { oldLines: collapsedOld, newLines: collapsedNew, kind: "collapse-repeated" };
}
function reduceToSingleLineChange(oldLines: string[], newLines: string[]): HunkVariant | undefined {
@@ -352,12 +355,12 @@ function reduceToSingleLineChange(oldLines: string[], newLines: string[]): HunkV
}
}
if (changedIndex === undefined) return undefined;
return { oldLines: [oldLines[changedIndex]], newLines: [newLines[changedIndex]] };
return { oldLines: [oldLines[changedIndex]], newLines: [newLines[changedIndex]], kind: "single-line" };
}
function buildFallbackVariants(hunk: DiffHunk): HunkVariant[] {
const variants: HunkVariant[] = [];
const base: HunkVariant = { oldLines: hunk.oldLines, newLines: hunk.newLines };
const base: HunkVariant = { oldLines: hunk.oldLines, newLines: hunk.newLines, kind: "trim-common" };
const trimmed = trimCommonContext(base.oldLines, base.newLines);
if (trimmed) variants.push(trimmed);
@@ -387,6 +390,11 @@ function buildFallbackVariants(hunk: DiffHunk): HunkVariant[] {
});
}
function filterFallbackVariants(variants: HunkVariant[], allowAggressive: boolean): HunkVariant[] {
if (allowAggressive) return variants;
return variants.filter(variant => variant.kind !== "collapse-repeated" && variant.kind !== "single-line");
}
function findContextRelativeMatch(
lines: string[],
patternLine: string,
@@ -414,6 +422,47 @@ function findContextRelativeMatch(
return undefined;
}
const AMBIGUITY_HINT_WINDOW = 200;
const MATCH_PREVIEW_CONTEXT = 2;
const MATCH_PREVIEW_MAX_LEN = 80;
function formatSequenceMatchPreview(lines: string[], startIdx: number): string {
const start = Math.max(0, startIdx - MATCH_PREVIEW_CONTEXT);
const end = Math.min(lines.length, startIdx + MATCH_PREVIEW_CONTEXT + 1);
const previewLines = lines.slice(start, end);
return previewLines
.map((line, i) => {
const num = start + i + 1;
const truncated =
line.length > MATCH_PREVIEW_MAX_LEN ? `${line.slice(0, MATCH_PREVIEW_MAX_LEN - 3)}...` : line;
return ` ${num} | ${truncated}`;
})
.join("\n");
}
function formatSequenceMatchPreviews(
lines: string[],
matchIndices: number[] | undefined,
matchCount: number | undefined,
): string | undefined {
if (!matchIndices || matchIndices.length === 0) return undefined;
const previews = matchIndices.map(index => formatSequenceMatchPreview(lines, index));
const moreMsg =
matchCount && matchCount > matchIndices.length ? ` (showing first ${matchIndices.length} of ${matchCount})` : "";
return `${previews.join("\n\n")}${moreMsg}`;
}
function chooseHintedMatch(
matchIndices: number[] | undefined,
hintIndex: number | undefined,
window: number,
): number | undefined {
if (!matchIndices || matchIndices.length === 0 || hintIndex === undefined) return undefined;
const candidates = matchIndices.filter(index => Math.abs(index - hintIndex) <= window);
if (candidates.length === 1) return candidates[0];
return undefined;
}
/** Get hint index from hunk's line number */
function getHunkHintIndex(hunk: DiffHunk, currentIndex: number): number | undefined {
if (hunk.oldStartLine === undefined) return undefined;
@@ -458,11 +507,17 @@ function findHierarchicalContext(
if (hintStart >= currentStart) {
const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy });
if (hintedResult.index !== undefined) {
return { ...hintedResult, matchCount: 1 };
return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] };
}
}
}
return { index: undefined, confidence: result.confidence, matchCount: result.matchCount };
return {
index: undefined,
confidence: result.confidence,
matchCount: result.matchCount,
matchIndices: result.matchIndices,
strategy: result.strategy,
};
}
if (result.index === undefined) {
@@ -471,7 +526,7 @@ function findHierarchicalContext(
if (hintStart >= currentStart) {
const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy });
if (hintedResult.index !== undefined) {
return { ...hintedResult, matchCount: 1 };
return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] };
}
}
}
@@ -494,17 +549,27 @@ function findHierarchicalContext(
const inner = spaceParts[spaceParts.length - 1];
const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy });
if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) {
return { index: undefined, confidence: outerResult.confidence, matchCount: outerResult.matchCount };
return {
index: undefined,
confidence: outerResult.confidence,
matchCount: outerResult.matchCount,
matchIndices: outerResult.matchIndices,
strategy: outerResult.strategy,
};
}
if (outerResult.index !== undefined) {
const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy });
if (innerResult.index !== undefined) {
return innerResult.matchCount && innerResult.matchCount > 1
? { ...innerResult, matchCount: 1 }
? { ...innerResult, matchCount: 1, matchIndices: [innerResult.index] }
: innerResult;
}
if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) {
return { ...innerResult, matchCount: 1 };
return {
...innerResult,
matchCount: 1,
matchIndices: innerResult.index !== undefined ? [innerResult.index] : innerResult.matchIndices,
};
}
}
}
@@ -516,7 +581,7 @@ function findHierarchicalContext(
const hintStart = Math.max(0, lineHint - 1);
const hintedResult = findContextLine(lines, context, hintStart, { allowFuzzy });
if (hintedResult.index !== undefined) {
return { ...hintedResult, matchCount: 1 };
return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] };
}
}
@@ -547,7 +612,13 @@ function findHierarchicalContext(
const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy });
if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) {
return { index: undefined, confidence: outerResult.confidence, matchCount: outerResult.matchCount };
return {
index: undefined,
confidence: outerResult.confidence,
matchCount: outerResult.matchCount,
matchIndices: outerResult.matchIndices,
strategy: outerResult.strategy,
};
}
if (outerResult.index === undefined) {
@@ -556,10 +627,16 @@ function findHierarchicalContext(
const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy });
if (innerResult.index !== undefined) {
return innerResult.matchCount && innerResult.matchCount > 1 ? { ...innerResult, matchCount: 1 } : innerResult;
return innerResult.matchCount && innerResult.matchCount > 1
? { ...innerResult, matchCount: 1, matchIndices: [innerResult.index] }
: innerResult;
}
if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) {
return { ...innerResult, matchCount: 1 };
return {
...innerResult,
matchCount: 1,
matchIndices: innerResult.index !== undefined ? [innerResult.index] : innerResult.matchIndices,
};
}
}
@@ -620,6 +697,7 @@ function attemptSequenceFallback(
currentIndex: number,
lineHint: number | undefined,
allowFuzzy: boolean,
allowAggressiveFallbacks: boolean,
): number | undefined {
if (hunk.oldLines.length === 0) return undefined;
const matchHint = getHunkHintIndex(hunk, currentIndex);
@@ -642,7 +720,7 @@ function attemptSequenceFallback(
return fallbackResult.index;
}
for (const variant of buildFallbackVariants(hunk)) {
for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
if (variant.oldLines.length === 0) continue;
const variantResult = findSequenceWithHint(
lines,
@@ -669,7 +747,7 @@ function applyCharacterMatch(
hunk: DiffHunk,
fuzzyThreshold: number,
allowFuzzy: boolean,
): string {
): { content: string; warnings: string[] } {
const oldText = hunk.oldLines.join("\n");
const newText = hunk.newLines.join("\n");
@@ -725,10 +803,18 @@ function applyCharacterMatch(
// Adjust indentation to match what was actually found
const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText);
const warnings: string[] = [];
if (matchOutcome.dominantFuzzy && matchOutcome.match) {
const similarity = Math.round(matchOutcome.match.confidence * 100);
warnings.push(
`Dominant fuzzy match selected in ${path} near line ${matchOutcome.match.startLine} (${similarity}% similar).`,
);
}
// Apply the replacement
const before = normalizedContent.substring(0, matchOutcome.match.startIndex);
const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length);
return before + adjustedNewText + after;
return { content: before + adjustedNewText + after, warnings };
}
function applyTrailingNewlinePolicy(content: string, hadFinalNewline: boolean): string {
@@ -746,8 +832,9 @@ function computeReplacements(
path: string,
hunks: DiffHunk[],
allowFuzzy: boolean,
): Replacement[] {
): { replacements: Replacement[]; warnings: string[] } {
const replacements: Replacement[] = [];
const warnings: string[] = [];
let lineIndex = 0;
for (const hunk of hunks) {
@@ -763,6 +850,7 @@ function computeReplacements(
);
}
const lineHint = hunk.oldStartLine;
const allowAggressiveFallbacks = hunk.changeContext !== undefined || lineHint !== undefined || hunk.isEndOfFile;
if (lineHint !== undefined && hunk.changeContext === undefined && !hunk.hasContextLines) {
lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1));
}
@@ -775,16 +863,26 @@ function computeReplacements(
contextIndex = idx;
if (idx === undefined || (result.matchCount !== undefined && result.matchCount > 1)) {
const fallback = attemptSequenceFallback(originalLines, hunk, lineIndex, lineHint, allowFuzzy);
const fallback = attemptSequenceFallback(
originalLines,
hunk,
lineIndex,
lineHint,
allowFuzzy,
allowAggressiveFallbacks,
);
if (fallback !== undefined) {
lineIndex = fallback;
} else if (result.matchCount !== undefined && result.matchCount > 1) {
const displayContext = hunk.changeContext.includes("\n")
? hunk.changeContext.split("\n").pop()
: hunk.changeContext;
const previews = formatSequenceMatchPreviews(originalLines, result.matchIndices, result.matchCount);
const strategyHint = result.strategy ? ` Matching strategy: ${result.strategy}.` : "";
const previewText = previews ? `\n\n${previews}` : "";
throw new ApplyPatchError(
`Found ${result.matchCount} matches for context '${displayContext}' in ${path}. ` +
`Add more surrounding context or additional @@ anchors to make it unique.`,
`Found ${result.matchCount} matches for context '${displayContext}' in ${path}.${strategyHint}` +
`${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`,
);
} else {
const displayContext = hunk.changeContext.includes("\n")
@@ -875,7 +973,7 @@ function computeReplacements(
}
if (searchResult.index === undefined || (searchResult.matchCount ?? 0) > 1) {
for (const variant of buildFallbackVariants(hunk)) {
for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
if (variant.oldLines.length === 0) continue;
const variantResult = findSequenceWithHint(
originalLines,
@@ -895,7 +993,7 @@ function computeReplacements(
}
if (searchResult.index === undefined && contextIndex !== undefined) {
for (const variant of buildFallbackVariants(hunk)) {
for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
if (variant.oldLines.length !== 1 || variant.newLines.length !== 1) continue;
const removedLine = variant.oldLines[0];
const hasSharedDuplicate = hunk.newLines.some(line => line.trim() === removedLine.trim());
@@ -929,11 +1027,38 @@ function computeReplacements(
}
}
if ((searchResult.matchCount ?? 0) > 1) {
const hintIndex = matchHint ?? (lineHint ? lineHint - 1 : undefined);
const hinted = chooseHintedMatch(searchResult.matchIndices, hintIndex, AMBIGUITY_HINT_WINDOW);
if (hinted !== undefined) {
searchResult = { ...searchResult, index: hinted, matchCount: 1 };
}
}
if (searchResult.index === undefined) {
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
const previews = formatSequenceMatchPreviews(
originalLines,
searchResult.matchIndices,
searchResult.matchCount,
);
const strategyHint = searchResult.strategy ? ` Matching strategy: ${searchResult.strategy}.` : "";
const previewText = previews ? `\n\n${previews}` : "";
throw new ApplyPatchError(
`Found ${searchResult.matchCount} matches for the text in ${path}. ` +
`Add more surrounding context or additional @@ anchors to make it unique.`,
`Found ${searchResult.matchCount} matches for the text in ${path}.${strategyHint}` +
`${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`,
);
}
const closest = findClosestSequenceMatch(originalLines, pattern, {
start: lineIndex,
eof: hunk.isEndOfFile,
});
if (closest.index !== undefined && closest.confidence > 0) {
const similarity = Math.round(closest.confidence * 100);
const preview = formatSequenceMatchPreview(originalLines, closest.index);
throw new ApplyPatchError(
`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}\n\n` +
`Closest match (${similarity}% similar) near line ${closest.index + 1}:\n${preview}`,
);
}
throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}`);
@@ -941,11 +1066,23 @@ function computeReplacements(
const found = searchResult.index;
if (searchResult.strategy === "fuzzy-dominant") {
const similarity = Math.round(searchResult.confidence * 100);
warnings.push(`Dominant fuzzy match selected in ${path} near line ${found + 1} (${similarity}% similar).`);
}
// Reject if match is ambiguous (prefix/substring matching found multiple matches)
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
const previews = formatSequenceMatchPreviews(
originalLines,
searchResult.matchIndices,
searchResult.matchCount,
);
const strategyHint = searchResult.strategy ? ` Matching strategy: ${searchResult.strategy}.` : "";
const previewText = previews ? `\n\n${previews}` : "";
throw new ApplyPatchError(
`Found ${searchResult.matchCount} matches for the text in ${path}. ` +
`Add more surrounding context or additional @@ anchors to make it unique.`,
`Found ${searchResult.matchCount} matches for the text in ${path}.${strategyHint}` +
`${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`,
);
}
@@ -990,7 +1127,27 @@ function computeReplacements(
// Sort by start index
replacements.sort((a, b) => a.startIndex - b.startIndex);
return replacements;
for (let i = 1; i < replacements.length; i++) {
const prev = replacements[i - 1];
const next = replacements[i];
const prevEnd = prev.startIndex + prev.oldLen;
if (next.startIndex < prevEnd) {
const formatRange = (replacement: Replacement): string => {
if (replacement.oldLen === 0) {
return `${replacement.startIndex + 1} (insertion)`;
}
return `${replacement.startIndex + 1}-${replacement.startIndex + replacement.oldLen}`;
};
const prevRange = formatRange(prev);
const nextRange = formatRange(next);
throw new ApplyPatchError(
`Overlapping hunks detected in ${path} at lines ${prevRange} and ${nextRange}. ` +
`Split hunks or add more context to avoid overlap.`,
);
}
}
return { replacements, warnings };
}
/**
@@ -1018,7 +1175,7 @@ function applyHunksToContent(
hunks: DiffHunk[],
fuzzyThreshold: number,
allowFuzzy: boolean,
): string {
): { content: string; warnings: string[] } {
const hadFinalNewline = originalContent.endsWith("\n");
// Detect simple replace pattern: single hunk, no @@ context, no context lines, has old lines to match
@@ -1032,8 +1189,8 @@ function applyHunksToContent(
hunk.oldStartLine === undefined && // No line hint to use for positioning
!hunk.isEndOfFile // No EOF targeting (prefer end of file)
) {
const content = applyCharacterMatch(originalContent, path, hunk, fuzzyThreshold, allowFuzzy);
return applyTrailingNewlinePolicy(content, hadFinalNewline);
const { content, warnings } = applyCharacterMatch(originalContent, path, hunk, fuzzyThreshold, allowFuzzy);
return { content: applyTrailingNewlinePolicy(content, hadFinalNewline), warnings };
}
}
@@ -1048,7 +1205,7 @@ function applyHunksToContent(
strippedTrailingEmpty = true;
}
const replacements = computeReplacements(originalLines, path, hunks, allowFuzzy);
const { replacements, warnings } = computeReplacements(originalLines, path, hunks, allowFuzzy);
const newLines = applyReplacements(originalLines, replacements);
// Restore the trailing empty element if we stripped it
@@ -1060,12 +1217,12 @@ function applyHunksToContent(
// Preserve original trailing newline behavior
if (hadFinalNewline && !content.endsWith("\n")) {
return `${content}\n`;
return { content: `${content}\n`, warnings };
}
if (!hadFinalNewline && content.endsWith("\n")) {
return content.slice(0, -1);
return { content: content.slice(0, -1), warnings };
}
return content;
return { content, warnings };
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -1178,7 +1335,13 @@ async function applyNormalizedPatch(
throw new ApplyPatchError("Diff contains no hunks");
}
const newContent = applyHunksToContent(normalizedContent, input.path, hunks, fuzzyThreshold, allowFuzzy);
const { content: newContent, warnings } = applyHunksToContent(
normalizedContent,
input.path,
hunks,
fuzzyThreshold,
allowFuzzy,
);
const finalContent = bom + restoreLineEndings(newContent, lineEnding);
const destPath = input.rename ? resolvePath(input.rename) : absolutePath;
const isMove = Boolean(input.rename) && destPath !== absolutePath;
@@ -1204,6 +1367,7 @@ async function applyNormalizedPatch(
oldContent: originalContent,
newContent: finalContent,
},
warnings: warnings.length > 0 ? warnings : undefined,
};
}
+135 -25
View File
@@ -5,7 +5,7 @@
* fallback strategies for finding text in files.
*/
import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize";
import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceSearchResult } from "./types";
import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceMatchStrategy, SequenceSearchResult } from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Constants
@@ -135,6 +135,7 @@ function computeLineOffsets(lines: string[]): number[] {
interface BestFuzzyMatchResult {
best?: FuzzyMatch;
aboveThresholdCount: number;
secondBestScore: number;
}
function findBestFuzzyMatchCore(
@@ -148,6 +149,7 @@ function findBestFuzzyMatchCore(
let best: FuzzyMatch | undefined;
let bestScore = -1;
let secondBestScore = -1;
let aboveThresholdCount = 0;
for (let start = 0; start <= contentLines.length - targetLines.length; start++) {
@@ -164,6 +166,7 @@ function findBestFuzzyMatchCore(
}
if (score > bestScore) {
secondBestScore = bestScore;
bestScore = score;
best = {
actualText: windowLines.join("\n"),
@@ -171,10 +174,12 @@ function findBestFuzzyMatchCore(
startLine: start + 1,
confidence: score,
};
} else if (score > secondBestScore) {
secondBestScore = score;
}
}
return { best, aboveThresholdCount };
return { best, aboveThresholdCount, secondBestScore };
}
function findBestFuzzyMatch(content: string, target: string, threshold: number): BestFuzzyMatchResult {
@@ -182,10 +187,10 @@ function findBestFuzzyMatch(content: string, target: string, threshold: number):
const targetLines = target.split("\n");
if (targetLines.length === 0 || target.length === 0) {
return { aboveThresholdCount: 0 };
return { aboveThresholdCount: 0, secondBestScore: 0 };
}
if (targetLines.length > contentLines.length) {
return { aboveThresholdCount: 0 };
return { aboveThresholdCount: 0, secondBestScore: 0 };
}
const offsets = computeLineOffsets(contentLines);
@@ -257,14 +262,25 @@ export function findMatch(
// Try fuzzy match
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
const { best, aboveThresholdCount } = findBestFuzzyMatch(content, target, threshold);
const { best, aboveThresholdCount, secondBestScore } = findBestFuzzyMatch(content, target, threshold);
if (!best) {
return {};
}
if (options.allowFuzzy && best.confidence >= threshold && aboveThresholdCount === 1) {
return { match: best, closest: best };
if (options.allowFuzzy && best.confidence >= threshold) {
if (aboveThresholdCount === 1) {
return { match: best, closest: best };
}
const dominantDelta = 0.08;
const dominantMin = 0.97;
if (
aboveThresholdCount > 1 &&
best.confidence >= dominantMin &&
best.confidence - secondBestScore >= dominantDelta
) {
return { match: best, closest: best, fuzzyMatches: aboveThresholdCount, dominantFuzzy: true };
}
}
return { closest: best, fuzzyMatches: aboveThresholdCount };
@@ -360,7 +376,7 @@ export function seekSequence(
const allowFuzzy = options?.allowFuzzy ?? true;
// Empty pattern matches immediately
if (pattern.length === 0) {
return { index: start, confidence: 1.0 };
return { index: start, confidence: 1.0, strategy: "exact" };
}
// Pattern longer than available content cannot match
@@ -376,35 +392,35 @@ export function seekSequence(
// Pass 1: Exact match
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, (a, b) => a === b)) {
return { index: i, confidence: 1.0 };
return { index: i, confidence: 1.0, strategy: "exact" };
}
}
// Pass 2: Trailing whitespace stripped
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) {
return { index: i, confidence: 0.99 };
return { index: i, confidence: 0.99, strategy: "trim-trailing" };
}
}
// Pass 3: Both leading and trailing whitespace stripped
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) {
return { index: i, confidence: 0.98 };
return { index: i, confidence: 0.98, strategy: "trim" };
}
}
// Pass 3b: Comment-prefix normalized match
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) {
return { index: i, confidence: 0.975 };
return { index: i, confidence: 0.975, strategy: "comment-prefix" };
}
}
// Pass 4: Normalize unicode punctuation
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) {
return { index: i, confidence: 0.97 };
return { index: i, confidence: 0.97, strategy: "unicode" };
}
}
@@ -416,14 +432,16 @@ export function seekSequence(
{
let firstMatch: number | undefined;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, lineStartsWithPattern)) {
if (firstMatch === undefined) firstMatch = i;
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 0.965, matchCount };
return { index: firstMatch, confidence: 0.965, matchCount, matchIndices, strategy: "prefix" };
}
}
@@ -431,14 +449,16 @@ export function seekSequence(
{
let firstMatch: number | undefined;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = from; i <= to; i++) {
if (matchesAt(lines, pattern, i, lineIncludesPattern)) {
if (firstMatch === undefined) firstMatch = i;
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 0.94, matchCount };
return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" };
}
}
@@ -464,16 +484,22 @@ export function seekSequence(
// Pass 7: Fuzzy matching - find best match above threshold
let bestIndex: number | undefined;
let bestScore = 0;
let secondBestScore = 0;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = searchStart; i <= maxStart; i++) {
const score = fuzzyScoreAt(lines, pattern, i);
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
if (score > bestScore) {
secondBestScore = bestScore;
bestScore = score;
bestIndex = i;
} else if (score > secondBestScore) {
secondBestScore = score;
}
}
@@ -483,16 +509,31 @@ export function seekSequence(
const score = fuzzyScoreAt(lines, pattern, i);
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
if (score > bestScore) {
secondBestScore = bestScore;
bestScore = score;
bestIndex = i;
} else if (score > secondBestScore) {
secondBestScore = score;
}
}
}
if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) {
return { index: bestIndex, confidence: bestScore, matchCount };
const dominantDelta = 0.08;
const dominantMin = 0.97;
if (matchCount > 1 && bestScore >= dominantMin && bestScore - secondBestScore >= dominantDelta) {
return {
index: bestIndex,
confidence: bestScore,
matchCount: 1,
matchIndices,
strategy: "fuzzy-dominant",
};
}
return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" };
}
// Pass 8: Character-based fuzzy matching via findMatch
@@ -510,13 +551,59 @@ export function seekSequence(
const matchedContent = contentText.substring(0, matchOutcome.match.startIndex);
const lineIndex = start + matchedContent.split("\n").length - 1;
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1;
return { index: lineIndex, confidence: matchOutcome.match.confidence, matchCount: fallbackMatchCount };
return {
index: lineIndex,
confidence: matchOutcome.match.confidence,
matchCount: fallbackMatchCount,
strategy: "character",
};
}
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches;
return { index: undefined, confidence: bestScore, matchCount: fallbackMatchCount };
}
export function findClosestSequenceMatch(
lines: string[],
pattern: string[],
options?: { start?: number; eof?: boolean },
): { index: number | undefined; confidence: number; strategy: SequenceMatchStrategy } {
if (pattern.length === 0) {
return { index: options?.start ?? 0, confidence: 1, strategy: "exact" };
}
if (pattern.length > lines.length) {
return { index: undefined, confidence: 0, strategy: "fuzzy" };
}
const start = options?.start ?? 0;
const eof = options?.eof ?? false;
const maxStart = lines.length - pattern.length;
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
let bestIndex: number | undefined;
let bestScore = 0;
for (let i = searchStart; i <= maxStart; i++) {
const score = fuzzyScoreAt(lines, pattern, i);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
if (eof && searchStart > start) {
for (let i = start; i < searchStart; i++) {
const score = fuzzyScoreAt(lines, pattern, i);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
}
return { index: bestIndex, confidence: bestScore, strategy: "fuzzy" };
}
/**
* Find a context line in the file using progressive matching strategies.
*
@@ -537,14 +624,16 @@ export function findContextLine(
{
let firstMatch: number | undefined;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = startFrom; i < lines.length; i++) {
if (lines[i] === context) {
if (firstMatch === undefined) firstMatch = i;
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 1.0, matchCount };
return { index: firstMatch, confidence: 1.0, matchCount, matchIndices, strategy: "exact" };
}
}
@@ -552,14 +641,16 @@ export function findContextLine(
{
let firstMatch: number | undefined;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = startFrom; i < lines.length; i++) {
if (lines[i].trim() === trimmedContext) {
if (firstMatch === undefined) firstMatch = i;
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 0.99, matchCount };
return { index: firstMatch, confidence: 0.99, matchCount, matchIndices, strategy: "trim" };
}
}
@@ -568,14 +659,16 @@ export function findContextLine(
{
let firstMatch: number | undefined;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = startFrom; i < lines.length; i++) {
if (normalizeUnicode(lines[i]) === normalizedContext) {
if (firstMatch === undefined) firstMatch = i;
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 0.98, matchCount };
return { index: firstMatch, confidence: 0.98, matchCount, matchIndices, strategy: "unicode" };
}
}
@@ -588,15 +681,17 @@ export function findContextLine(
if (contextNorm.length > 0) {
let firstMatch: number | undefined;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = startFrom; i < lines.length; i++) {
const lineNorm = normalizeForFuzzy(lines[i]);
if (lineNorm.startsWith(contextNorm)) {
if (firstMatch === undefined) firstMatch = i;
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 0.96, matchCount };
return { index: firstMatch, confidence: 0.96, matchCount, matchIndices, strategy: "prefix" };
}
}
@@ -613,10 +708,17 @@ export function findContextLine(
allSubstringMatches.push({ index: i, ratio });
}
}
const matchIndices = allSubstringMatches.slice(0, 5).map(match => match.index);
// If exactly one substring match, accept it regardless of ratio
if (allSubstringMatches.length === 1) {
return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: 1 };
return {
index: allSubstringMatches[0].index,
confidence: 0.94,
matchCount: 1,
matchIndices,
strategy: "substring",
};
}
// Multiple matches: filter by ratio to disambiguate
@@ -629,13 +731,19 @@ export function findContextLine(
}
}
if (matchCount > 0) {
return { index: firstMatch, confidence: 0.94, matchCount };
return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" };
}
// If we had substring matches but none passed ratio filter,
// return ambiguous result so caller knows matches exist
if (allSubstringMatches.length > 1) {
return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: allSubstringMatches.length };
return {
index: allSubstringMatches[0].index,
confidence: 0.94,
matchCount: allSubstringMatches.length,
matchIndices,
strategy: "substring",
};
}
}
@@ -643,12 +751,14 @@ export function findContextLine(
let bestIndex: number | undefined;
let bestScore = 0;
let matchCount = 0;
const matchIndices: number[] = [];
for (let i = startFrom; i < lines.length; i++) {
const lineNorm = normalizeForFuzzy(lines[i]);
const score = similarity(lineNorm, contextNorm);
if (score >= CONTEXT_FUZZY_THRESHOLD) {
matchCount++;
if (matchIndices.length < 5) matchIndices.push(i);
}
if (score > bestScore) {
bestScore = score;
@@ -657,7 +767,7 @@ export function findContextLine(
}
if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) {
return { index: bestIndex, confidence: bestScore, matchCount };
return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" };
}
if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) {
+25 -2
View File
@@ -167,6 +167,27 @@ class LspFileSystem implements FileSystem {
}
}
function mergeDiagnosticsWithWarnings(
diagnostics: FileDiagnosticsResult | undefined,
warnings: string[],
): FileDiagnosticsResult | undefined {
if (warnings.length === 0) return diagnostics;
const warningMessages = warnings.map(warning => `patch: ${warning}`);
if (!diagnostics) {
return {
server: "patch",
messages: warningMessages,
summary: `Patch warnings: ${warnings.length}`,
errored: false,
};
}
return {
...diagnostics,
messages: [...warningMessages, ...diagnostics.messages],
summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
@@ -319,9 +340,11 @@ export class EditTool implements AgentTool<TInput> {
const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal);
diagnostics ??= flushedDiagnostics;
}
const patchWarnings = result.warnings ?? [];
const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings);
const meta = outputMeta()
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
.diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? [])
.get();
return {
@@ -329,7 +352,7 @@ export class EditTool implements AgentTool<TInput> {
details: {
diff: diffResult.diff,
firstChangedLine: diffResult.firstChangedLine,
diagnostics,
diagnostics: mergedDiagnostics,
op,
rename: effRename,
meta,
@@ -328,6 +328,11 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
const nextLine = lines[i + 1];
if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) {
break;
}
if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
if (parsedLines === 0) {
+25
View File
@@ -46,9 +46,23 @@ export interface MatchOutcome {
occurrencePreviews?: string[];
/** Number of fuzzy matches above threshold */
fuzzyMatches?: number;
/** True when a dominant fuzzy match was accepted despite multiple candidates */
dominantFuzzy?: boolean;
}
/** Result of a sequence search */
export type SequenceMatchStrategy =
| "exact"
| "trim-trailing"
| "trim"
| "comment-prefix"
| "unicode"
| "prefix"
| "substring"
| "fuzzy"
| "fuzzy-dominant"
| "character";
export interface SequenceSearchResult {
/** Starting line index of the match (0-indexed) */
index: number | undefined;
@@ -56,9 +70,15 @@ export interface SequenceSearchResult {
confidence: number;
/** Number of matches at the same confidence level (for ambiguity detection) */
matchCount?: number;
/** Sample of matching indices (0-indexed, up to a small limit) */
matchIndices?: number[];
/** Matching strategy used */
strategy?: SequenceMatchStrategy;
}
/** Result of a context line search */
export type ContextMatchStrategy = "exact" | "trim" | "unicode" | "prefix" | "substring" | "fuzzy";
export interface ContextLineResult {
/** Index of the matching line (0-indexed) */
index: number | undefined;
@@ -66,6 +86,10 @@ export interface ContextLineResult {
confidence: number;
/** Number of matches at the same confidence level (for ambiguity detection) */
matchCount?: number;
/** Sample of matching indices (0-indexed, up to a small limit) */
matchIndices?: number[];
/** Matching strategy used */
strategy?: ContextMatchStrategy;
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -133,6 +157,7 @@ export interface FileChange {
/** Result of applying a patch */
export interface ApplyPatchResult {
change: FileChange;
warnings?: string[];
}
/** Options for applying a patch */