@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/docs/sdk.md +1 -1
- package/package.json +5 -5
- package/scripts/generate-template.ts +6 -6
- package/src/cli/args.ts +3 -0
- package/src/core/agent-session.ts +39 -0
- package/src/core/bash-executor.ts +3 -3
- package/src/core/cursor/exec-bridge.ts +95 -88
- package/src/core/custom-commands/bundled/review/index.ts +142 -145
- package/src/core/custom-commands/bundled/wt/index.ts +68 -66
- package/src/core/custom-commands/loader.ts +4 -6
- package/src/core/custom-tools/index.ts +2 -2
- package/src/core/custom-tools/loader.ts +66 -61
- package/src/core/custom-tools/types.ts +4 -4
- package/src/core/custom-tools/wrapper.ts +61 -25
- package/src/core/event-bus.ts +19 -47
- package/src/core/extensions/index.ts +8 -4
- package/src/core/extensions/loader.ts +160 -120
- package/src/core/extensions/types.ts +4 -4
- package/src/core/extensions/wrapper.ts +149 -100
- package/src/core/hooks/index.ts +1 -1
- package/src/core/hooks/tool-wrapper.ts +96 -70
- package/src/core/hooks/types.ts +1 -2
- package/src/core/index.ts +1 -0
- package/src/core/mcp/index.ts +6 -2
- package/src/core/mcp/json-rpc.ts +88 -0
- package/src/core/mcp/loader.ts +22 -4
- package/src/core/mcp/manager.ts +202 -48
- package/src/core/mcp/tool-bridge.ts +143 -55
- package/src/core/mcp/tool-cache.ts +122 -0
- package/src/core/python-executor.ts +3 -9
- package/src/core/sdk.ts +33 -32
- package/src/core/session-manager.ts +30 -0
- package/src/core/settings-manager.ts +34 -1
- package/src/core/ssh/ssh-executor.ts +6 -84
- package/src/core/streaming-output.ts +107 -53
- package/src/core/tools/ask.ts +92 -93
- package/src/core/tools/bash.ts +103 -94
- package/src/core/tools/calculator.ts +41 -26
- package/src/core/tools/complete.ts +76 -66
- package/src/core/tools/context.ts +22 -24
- package/src/core/tools/exa/index.ts +1 -1
- package/src/core/tools/exa/mcp-client.ts +56 -101
- package/src/core/tools/find.ts +250 -253
- package/src/core/tools/git.ts +39 -33
- package/src/core/tools/grep.ts +440 -427
- package/src/core/tools/index.ts +62 -61
- package/src/core/tools/ls.ts +119 -114
- package/src/core/tools/lsp/clients/biome-client.ts +5 -7
- package/src/core/tools/lsp/clients/index.ts +4 -4
- package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
- package/src/core/tools/lsp/config.ts +2 -2
- package/src/core/tools/lsp/index.ts +604 -578
- package/src/core/tools/notebook.ts +121 -119
- package/src/core/tools/output.ts +163 -147
- package/src/core/tools/patch/applicator.ts +1100 -0
- package/src/core/tools/patch/diff.ts +362 -0
- package/src/core/tools/patch/fuzzy.ts +647 -0
- package/src/core/tools/patch/index.ts +430 -0
- package/src/core/tools/patch/normalize.ts +220 -0
- package/src/core/tools/patch/normative.ts +49 -0
- package/src/core/tools/patch/parser.ts +528 -0
- package/src/core/tools/patch/shared.ts +228 -0
- package/src/core/tools/patch/types.ts +244 -0
- package/src/core/tools/python.ts +139 -136
- package/src/core/tools/read.ts +237 -216
- package/src/core/tools/render-utils.ts +196 -77
- package/src/core/tools/renderers.ts +1 -1
- package/src/core/tools/ssh.ts +99 -80
- package/src/core/tools/task/executor.ts +11 -7
- package/src/core/tools/task/index.ts +352 -343
- package/src/core/tools/task/worker.ts +13 -23
- package/src/core/tools/todo-write.ts +74 -59
- package/src/core/tools/web-fetch.ts +54 -47
- package/src/core/tools/web-search/index.ts +27 -16
- package/src/core/tools/write.ts +73 -44
- package/src/core/ttsr.ts +106 -152
- package/src/core/voice.ts +49 -39
- package/src/index.ts +16 -12
- package/src/lib/worktree/index.ts +1 -9
- package/src/modes/interactive/components/diff.ts +15 -8
- package/src/modes/interactive/components/settings-defs.ts +24 -0
- package/src/modes/interactive/components/tool-execution.ts +34 -6
- package/src/modes/interactive/controllers/event-controller.ts +6 -19
- package/src/modes/interactive/controllers/input-controller.ts +1 -1
- package/src/modes/interactive/utils/ui-helpers.ts +5 -1
- package/src/modes/rpc/rpc-mode.ts +99 -81
- package/src/prompts/tools/patch.md +76 -0
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/{edit.md → replace.md} +1 -0
- package/src/utils/shell.ts +0 -40
- package/src/core/tools/edit-diff.ts +0 -574
- package/src/core/tools/edit.ts +0 -345
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy matching utilities for the edit tool.
|
|
3
|
+
*
|
|
4
|
+
* Provides both character-level and line-level fuzzy matching with progressive
|
|
5
|
+
* fallback strategies for finding text in files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize";
|
|
9
|
+
import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceSearchResult } from "./types";
|
|
10
|
+
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
// Constants
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
/** Default similarity threshold for fuzzy matching */
|
|
16
|
+
export const DEFAULT_FUZZY_THRESHOLD = 0.95;
|
|
17
|
+
|
|
18
|
+
/** Threshold for sequence-based fuzzy matching */
|
|
19
|
+
const SEQUENCE_FUZZY_THRESHOLD = 0.92;
|
|
20
|
+
|
|
21
|
+
/** Fallback threshold for line-based matching */
|
|
22
|
+
const FALLBACK_THRESHOLD = 0.8;
|
|
23
|
+
|
|
24
|
+
/** Threshold for context line matching */
|
|
25
|
+
const CONTEXT_FUZZY_THRESHOLD = 0.8;
|
|
26
|
+
|
|
27
|
+
/** Minimum length for partial/substring matching */
|
|
28
|
+
const PARTIAL_MATCH_MIN_LENGTH = 6;
|
|
29
|
+
|
|
30
|
+
/** Minimum ratio of pattern to line length for substring match */
|
|
31
|
+
const PARTIAL_MATCH_MIN_RATIO = 0.3;
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// Core Algorithms
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
/** Compute Levenshtein distance between two strings */
|
|
38
|
+
export function levenshteinDistance(a: string, b: string): number {
|
|
39
|
+
if (a === b) return 0;
|
|
40
|
+
const aLen = a.length;
|
|
41
|
+
const bLen = b.length;
|
|
42
|
+
if (aLen === 0) return bLen;
|
|
43
|
+
if (bLen === 0) return aLen;
|
|
44
|
+
|
|
45
|
+
let prev = new Array<number>(bLen + 1);
|
|
46
|
+
let curr = new Array<number>(bLen + 1);
|
|
47
|
+
for (let j = 0; j <= bLen; j++) {
|
|
48
|
+
prev[j] = j;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let i = 1; i <= aLen; i++) {
|
|
52
|
+
curr[0] = i;
|
|
53
|
+
const aCode = a.charCodeAt(i - 1);
|
|
54
|
+
for (let j = 1; j <= bLen; j++) {
|
|
55
|
+
const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1;
|
|
56
|
+
const deletion = prev[j] + 1;
|
|
57
|
+
const insertion = curr[j - 1] + 1;
|
|
58
|
+
const substitution = prev[j - 1] + cost;
|
|
59
|
+
curr[j] = Math.min(deletion, insertion, substitution);
|
|
60
|
+
}
|
|
61
|
+
const tmp = prev;
|
|
62
|
+
prev = curr;
|
|
63
|
+
curr = tmp;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return prev[bLen];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compute similarity score between two strings (0 to 1) */
|
|
70
|
+
export function similarity(a: string, b: string): number {
|
|
71
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
72
|
+
const maxLen = Math.max(a.length, b.length);
|
|
73
|
+
if (maxLen === 0) return 1;
|
|
74
|
+
const distance = levenshteinDistance(a, b);
|
|
75
|
+
return 1 - distance / maxLen;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
79
|
+
// Line-Based Utilities
|
|
80
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
81
|
+
|
|
82
|
+
/** Compute relative indent depths for lines */
|
|
83
|
+
function computeRelativeIndentDepths(lines: string[]): number[] {
|
|
84
|
+
const indents = lines.map(countLeadingWhitespace);
|
|
85
|
+
const nonEmptyIndents: number[] = [];
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
if (lines[i].trim().length > 0) {
|
|
88
|
+
nonEmptyIndents.push(indents[i]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const minIndent = nonEmptyIndents.length > 0 ? Math.min(...nonEmptyIndents) : 0;
|
|
92
|
+
const indentSteps = nonEmptyIndents.map((indent) => indent - minIndent).filter((step) => step > 0);
|
|
93
|
+
const indentUnit = indentSteps.length > 0 ? Math.min(...indentSteps) : 1;
|
|
94
|
+
|
|
95
|
+
return lines.map((line, index) => {
|
|
96
|
+
if (line.trim().length === 0) return 0;
|
|
97
|
+
if (indentUnit <= 0) return 0;
|
|
98
|
+
const relativeIndent = indents[index] - minIndent;
|
|
99
|
+
return Math.round(relativeIndent / indentUnit);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Normalize lines for matching, optionally including indent depth */
|
|
104
|
+
function normalizeLines(lines: string[], includeDepth = true): string[] {
|
|
105
|
+
const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null;
|
|
106
|
+
return lines.map((line, index) => {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
const prefix = indentDepths ? `${indentDepths[index]}|` : "|";
|
|
109
|
+
if (trimmed.length === 0) return prefix;
|
|
110
|
+
return `${prefix}${normalizeForFuzzy(trimmed)}`;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Compute character offsets for each line in content */
|
|
115
|
+
function computeLineOffsets(lines: string[]): number[] {
|
|
116
|
+
const offsets: number[] = [];
|
|
117
|
+
let offset = 0;
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
offsets.push(offset);
|
|
120
|
+
offset += lines[i].length;
|
|
121
|
+
if (i < lines.length - 1) offset += 1; // newline
|
|
122
|
+
}
|
|
123
|
+
return offsets;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
127
|
+
// Character-Level Fuzzy Match (for replace mode)
|
|
128
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
129
|
+
|
|
130
|
+
interface BestFuzzyMatchResult {
|
|
131
|
+
best?: FuzzyMatch;
|
|
132
|
+
aboveThresholdCount: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findBestFuzzyMatchCore(
|
|
136
|
+
contentLines: string[],
|
|
137
|
+
targetLines: string[],
|
|
138
|
+
offsets: number[],
|
|
139
|
+
threshold: number,
|
|
140
|
+
includeDepth: boolean,
|
|
141
|
+
): BestFuzzyMatchResult {
|
|
142
|
+
const targetNormalized = normalizeLines(targetLines, includeDepth);
|
|
143
|
+
|
|
144
|
+
let best: FuzzyMatch | undefined;
|
|
145
|
+
let bestScore = -1;
|
|
146
|
+
let aboveThresholdCount = 0;
|
|
147
|
+
|
|
148
|
+
for (let start = 0; start <= contentLines.length - targetLines.length; start++) {
|
|
149
|
+
const windowLines = contentLines.slice(start, start + targetLines.length);
|
|
150
|
+
const windowNormalized = normalizeLines(windowLines, includeDepth);
|
|
151
|
+
let score = 0;
|
|
152
|
+
for (let i = 0; i < targetLines.length; i++) {
|
|
153
|
+
score += similarity(targetNormalized[i], windowNormalized[i]);
|
|
154
|
+
}
|
|
155
|
+
score = score / targetLines.length;
|
|
156
|
+
|
|
157
|
+
if (score >= threshold) {
|
|
158
|
+
aboveThresholdCount++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (score > bestScore) {
|
|
162
|
+
bestScore = score;
|
|
163
|
+
best = {
|
|
164
|
+
actualText: windowLines.join("\n"),
|
|
165
|
+
startIndex: offsets[start],
|
|
166
|
+
startLine: start + 1,
|
|
167
|
+
confidence: score,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { best, aboveThresholdCount };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findBestFuzzyMatch(content: string, target: string, threshold: number): BestFuzzyMatchResult {
|
|
176
|
+
const contentLines = content.split("\n");
|
|
177
|
+
const targetLines = target.split("\n");
|
|
178
|
+
|
|
179
|
+
if (targetLines.length === 0 || target.length === 0) {
|
|
180
|
+
return { aboveThresholdCount: 0 };
|
|
181
|
+
}
|
|
182
|
+
if (targetLines.length > contentLines.length) {
|
|
183
|
+
return { aboveThresholdCount: 0 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const offsets = computeLineOffsets(contentLines);
|
|
187
|
+
let result = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, true);
|
|
188
|
+
|
|
189
|
+
// Retry without indent depth if match is close but below threshold
|
|
190
|
+
if (result.best && result.best.confidence < threshold && result.best.confidence >= FALLBACK_THRESHOLD) {
|
|
191
|
+
const noDepthResult = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, false);
|
|
192
|
+
if (noDepthResult.best && noDepthResult.best.confidence > result.best.confidence) {
|
|
193
|
+
result = noDepthResult;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Find a match for target text within content.
|
|
202
|
+
* Used primarily for replace-mode edits.
|
|
203
|
+
*/
|
|
204
|
+
export function findMatch(
|
|
205
|
+
content: string,
|
|
206
|
+
target: string,
|
|
207
|
+
options: { allowFuzzy: boolean; threshold?: number },
|
|
208
|
+
): MatchOutcome {
|
|
209
|
+
if (target.length === 0) {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Try exact match first
|
|
214
|
+
const exactIndex = content.indexOf(target);
|
|
215
|
+
if (exactIndex !== -1) {
|
|
216
|
+
const occurrences = content.split(target).length - 1;
|
|
217
|
+
if (occurrences > 1) {
|
|
218
|
+
return { occurrences };
|
|
219
|
+
}
|
|
220
|
+
const startLine = content.slice(0, exactIndex).split("\n").length;
|
|
221
|
+
return {
|
|
222
|
+
match: {
|
|
223
|
+
actualText: target,
|
|
224
|
+
startIndex: exactIndex,
|
|
225
|
+
startLine,
|
|
226
|
+
confidence: 1,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Try fuzzy match
|
|
232
|
+
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
233
|
+
const { best, aboveThresholdCount } = findBestFuzzyMatch(content, target, threshold);
|
|
234
|
+
|
|
235
|
+
if (!best) {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (options.allowFuzzy && best.confidence >= threshold && aboveThresholdCount === 1) {
|
|
240
|
+
return { match: best, closest: best };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { closest: best, fuzzyMatches: aboveThresholdCount };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
247
|
+
// Line-Based Sequence Match (for patch mode)
|
|
248
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
249
|
+
|
|
250
|
+
/** Check if pattern matches lines starting at index using comparison function */
|
|
251
|
+
function matchesAt(lines: string[], pattern: string[], i: number, compare: (a: string, b: string) => boolean): boolean {
|
|
252
|
+
for (let j = 0; j < pattern.length; j++) {
|
|
253
|
+
if (!compare(lines[i + j], pattern[j])) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Compute average similarity score for pattern at position */
|
|
261
|
+
function fuzzyScoreAt(lines: string[], pattern: string[], i: number): number {
|
|
262
|
+
let totalScore = 0;
|
|
263
|
+
for (let j = 0; j < pattern.length; j++) {
|
|
264
|
+
const lineNorm = normalizeForFuzzy(lines[i + j]);
|
|
265
|
+
const patternNorm = normalizeForFuzzy(pattern[j]);
|
|
266
|
+
totalScore += similarity(lineNorm, patternNorm);
|
|
267
|
+
}
|
|
268
|
+
return totalScore / pattern.length;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Check if line starts with pattern (normalized) */
|
|
272
|
+
function lineStartsWithPattern(line: string, pattern: string): boolean {
|
|
273
|
+
const lineNorm = normalizeForFuzzy(line);
|
|
274
|
+
const patternNorm = normalizeForFuzzy(pattern);
|
|
275
|
+
if (patternNorm.length === 0) return lineNorm.length === 0;
|
|
276
|
+
return lineNorm.startsWith(patternNorm);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Check if line contains pattern as significant substring */
|
|
280
|
+
function lineIncludesPattern(line: string, pattern: string): boolean {
|
|
281
|
+
const lineNorm = normalizeForFuzzy(line);
|
|
282
|
+
const patternNorm = normalizeForFuzzy(pattern);
|
|
283
|
+
if (patternNorm.length === 0) return lineNorm.length === 0;
|
|
284
|
+
if (patternNorm.length < PARTIAL_MATCH_MIN_LENGTH) return false;
|
|
285
|
+
if (!lineNorm.includes(patternNorm)) return false;
|
|
286
|
+
return patternNorm.length / Math.max(1, lineNorm.length) >= PARTIAL_MATCH_MIN_RATIO;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function stripCommentPrefix(line: string): string {
|
|
290
|
+
let trimmed = line.trimStart();
|
|
291
|
+
if (trimmed.startsWith("/*")) {
|
|
292
|
+
trimmed = trimmed.slice(2);
|
|
293
|
+
} else if (trimmed.startsWith("*/")) {
|
|
294
|
+
trimmed = trimmed.slice(2);
|
|
295
|
+
} else if (trimmed.startsWith("//")) {
|
|
296
|
+
trimmed = trimmed.slice(2);
|
|
297
|
+
} else if (trimmed.startsWith("*")) {
|
|
298
|
+
trimmed = trimmed.slice(1);
|
|
299
|
+
} else if (trimmed.startsWith("#")) {
|
|
300
|
+
trimmed = trimmed.slice(1);
|
|
301
|
+
} else if (trimmed.startsWith(";")) {
|
|
302
|
+
trimmed = trimmed.slice(1);
|
|
303
|
+
} else if (trimmed.startsWith("/") && trimmed[1] === " ") {
|
|
304
|
+
trimmed = trimmed.slice(1);
|
|
305
|
+
}
|
|
306
|
+
return trimmed.trimStart();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Find a sequence of pattern lines within content lines.
|
|
311
|
+
*
|
|
312
|
+
* Attempts matches with decreasing strictness:
|
|
313
|
+
* 1. Exact match
|
|
314
|
+
* 2. Trailing whitespace ignored
|
|
315
|
+
* 3. All whitespace trimmed
|
|
316
|
+
* 4. Unicode punctuation normalized
|
|
317
|
+
* 5. Prefix match (pattern is prefix of line)
|
|
318
|
+
* 6. Substring match (pattern is substring of line)
|
|
319
|
+
* 7. Fuzzy similarity match
|
|
320
|
+
*
|
|
321
|
+
* @param lines - The lines of the file content
|
|
322
|
+
* @param pattern - The lines to search for
|
|
323
|
+
* @param start - Starting index for the search
|
|
324
|
+
* @param eof - If true, prefer matching at end of file first
|
|
325
|
+
*/
|
|
326
|
+
export function seekSequence(
|
|
327
|
+
lines: string[],
|
|
328
|
+
pattern: string[],
|
|
329
|
+
start: number,
|
|
330
|
+
eof: boolean,
|
|
331
|
+
options?: { allowFuzzy?: boolean },
|
|
332
|
+
): SequenceSearchResult {
|
|
333
|
+
const allowFuzzy = options?.allowFuzzy ?? true;
|
|
334
|
+
// Empty pattern matches immediately
|
|
335
|
+
if (pattern.length === 0) {
|
|
336
|
+
return { index: start, confidence: 1.0 };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Pattern longer than available content cannot match
|
|
340
|
+
if (pattern.length > lines.length) {
|
|
341
|
+
return { index: undefined, confidence: 0 };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Determine search start position
|
|
345
|
+
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
|
|
346
|
+
const maxStart = lines.length - pattern.length;
|
|
347
|
+
|
|
348
|
+
const runExactPasses = (from: number, to: number): SequenceSearchResult | undefined => {
|
|
349
|
+
// Pass 1: Exact match
|
|
350
|
+
for (let i = from; i <= to; i++) {
|
|
351
|
+
if (matchesAt(lines, pattern, i, (a, b) => a === b)) {
|
|
352
|
+
return { index: i, confidence: 1.0 };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Pass 2: Trailing whitespace stripped
|
|
357
|
+
for (let i = from; i <= to; i++) {
|
|
358
|
+
if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) {
|
|
359
|
+
return { index: i, confidence: 0.99 };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Pass 3: Both leading and trailing whitespace stripped
|
|
364
|
+
for (let i = from; i <= to; i++) {
|
|
365
|
+
if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) {
|
|
366
|
+
return { index: i, confidence: 0.98 };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Pass 3b: Comment-prefix normalized match
|
|
371
|
+
for (let i = from; i <= to; i++) {
|
|
372
|
+
if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) {
|
|
373
|
+
return { index: i, confidence: 0.975 };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Pass 4: Normalize unicode punctuation
|
|
378
|
+
for (let i = from; i <= to; i++) {
|
|
379
|
+
if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) {
|
|
380
|
+
return { index: i, confidence: 0.97 };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!allowFuzzy) {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Pass 5: Partial line prefix match (track all matches for ambiguity detection)
|
|
389
|
+
{
|
|
390
|
+
let firstMatch: number | undefined;
|
|
391
|
+
let matchCount = 0;
|
|
392
|
+
for (let i = from; i <= to; i++) {
|
|
393
|
+
if (matchesAt(lines, pattern, i, lineStartsWithPattern)) {
|
|
394
|
+
if (firstMatch === undefined) firstMatch = i;
|
|
395
|
+
matchCount++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (matchCount > 0) {
|
|
399
|
+
return { index: firstMatch, confidence: 0.965, matchCount };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Pass 6: Partial line substring match (track all matches for ambiguity detection)
|
|
404
|
+
{
|
|
405
|
+
let firstMatch: number | undefined;
|
|
406
|
+
let matchCount = 0;
|
|
407
|
+
for (let i = from; i <= to; i++) {
|
|
408
|
+
if (matchesAt(lines, pattern, i, lineIncludesPattern)) {
|
|
409
|
+
if (firstMatch === undefined) firstMatch = i;
|
|
410
|
+
matchCount++;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (matchCount > 0) {
|
|
414
|
+
return { index: firstMatch, confidence: 0.94, matchCount };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return undefined;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const primaryPassResult = runExactPasses(searchStart, maxStart);
|
|
422
|
+
if (primaryPassResult) {
|
|
423
|
+
return primaryPassResult;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (eof && searchStart > start) {
|
|
427
|
+
const fromStartResult = runExactPasses(start, maxStart);
|
|
428
|
+
if (fromStartResult) {
|
|
429
|
+
return fromStartResult;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!allowFuzzy) {
|
|
434
|
+
return { index: undefined, confidence: 0 };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Pass 7: Fuzzy matching - find best match above threshold
|
|
438
|
+
let bestIndex: number | undefined;
|
|
439
|
+
let bestScore = 0;
|
|
440
|
+
let matchCount = 0;
|
|
441
|
+
|
|
442
|
+
for (let i = searchStart; i <= maxStart; i++) {
|
|
443
|
+
const score = fuzzyScoreAt(lines, pattern, i);
|
|
444
|
+
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
|
|
445
|
+
matchCount++;
|
|
446
|
+
}
|
|
447
|
+
if (score > bestScore) {
|
|
448
|
+
bestScore = score;
|
|
449
|
+
bestIndex = i;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Also search from start if eof mode started from end
|
|
454
|
+
if (eof && searchStart > start) {
|
|
455
|
+
for (let i = start; i < searchStart; i++) {
|
|
456
|
+
const score = fuzzyScoreAt(lines, pattern, i);
|
|
457
|
+
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
|
|
458
|
+
matchCount++;
|
|
459
|
+
}
|
|
460
|
+
if (score > bestScore) {
|
|
461
|
+
bestScore = score;
|
|
462
|
+
bestIndex = i;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) {
|
|
468
|
+
return { index: bestIndex, confidence: bestScore, matchCount };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Pass 8: Character-based fuzzy matching via findMatch
|
|
472
|
+
// This is the final fallback for when line-based matching fails
|
|
473
|
+
const CHARACTER_MATCH_THRESHOLD = 0.92;
|
|
474
|
+
const patternText = pattern.join("\n");
|
|
475
|
+
const contentText = lines.slice(start).join("\n");
|
|
476
|
+
const matchOutcome = findMatch(contentText, patternText, {
|
|
477
|
+
allowFuzzy: true,
|
|
478
|
+
threshold: CHARACTER_MATCH_THRESHOLD,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (matchOutcome.match) {
|
|
482
|
+
// Convert character index back to line index
|
|
483
|
+
const matchedContent = contentText.substring(0, matchOutcome.match.startIndex);
|
|
484
|
+
const lineIndex = start + matchedContent.split("\n").length - 1;
|
|
485
|
+
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1;
|
|
486
|
+
return { index: lineIndex, confidence: matchOutcome.match.confidence, matchCount: fallbackMatchCount };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches;
|
|
490
|
+
return { index: undefined, confidence: bestScore, matchCount: fallbackMatchCount };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Find a context line in the file using progressive matching strategies.
|
|
495
|
+
*
|
|
496
|
+
* @param lines - The lines of the file content
|
|
497
|
+
* @param context - The context line to search for
|
|
498
|
+
* @param startFrom - Starting index for the search
|
|
499
|
+
*/
|
|
500
|
+
export function findContextLine(
|
|
501
|
+
lines: string[],
|
|
502
|
+
context: string,
|
|
503
|
+
startFrom: number,
|
|
504
|
+
options?: { allowFuzzy?: boolean; skipFunctionFallback?: boolean },
|
|
505
|
+
): ContextLineResult {
|
|
506
|
+
const allowFuzzy = options?.allowFuzzy ?? true;
|
|
507
|
+
const trimmedContext = context.trim();
|
|
508
|
+
|
|
509
|
+
// Pass 1: Exact line match
|
|
510
|
+
{
|
|
511
|
+
let firstMatch: number | undefined;
|
|
512
|
+
let matchCount = 0;
|
|
513
|
+
for (let i = startFrom; i < lines.length; i++) {
|
|
514
|
+
if (lines[i] === context) {
|
|
515
|
+
if (firstMatch === undefined) firstMatch = i;
|
|
516
|
+
matchCount++;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (matchCount > 0) {
|
|
520
|
+
return { index: firstMatch, confidence: 1.0, matchCount };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Pass 2: Trimmed match
|
|
525
|
+
{
|
|
526
|
+
let firstMatch: number | undefined;
|
|
527
|
+
let matchCount = 0;
|
|
528
|
+
for (let i = startFrom; i < lines.length; i++) {
|
|
529
|
+
if (lines[i].trim() === trimmedContext) {
|
|
530
|
+
if (firstMatch === undefined) firstMatch = i;
|
|
531
|
+
matchCount++;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (matchCount > 0) {
|
|
535
|
+
return { index: firstMatch, confidence: 0.99, matchCount };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Pass 3: Unicode normalization match
|
|
540
|
+
const normalizedContext = normalizeUnicode(context);
|
|
541
|
+
{
|
|
542
|
+
let firstMatch: number | undefined;
|
|
543
|
+
let matchCount = 0;
|
|
544
|
+
for (let i = startFrom; i < lines.length; i++) {
|
|
545
|
+
if (normalizeUnicode(lines[i]) === normalizedContext) {
|
|
546
|
+
if (firstMatch === undefined) firstMatch = i;
|
|
547
|
+
matchCount++;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (matchCount > 0) {
|
|
551
|
+
return { index: firstMatch, confidence: 0.98, matchCount };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!allowFuzzy) {
|
|
556
|
+
return { index: undefined, confidence: 0 };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Pass 4: Prefix match (file line starts with context)
|
|
560
|
+
const contextNorm = normalizeForFuzzy(context);
|
|
561
|
+
if (contextNorm.length > 0) {
|
|
562
|
+
let firstMatch: number | undefined;
|
|
563
|
+
let matchCount = 0;
|
|
564
|
+
for (let i = startFrom; i < lines.length; i++) {
|
|
565
|
+
const lineNorm = normalizeForFuzzy(lines[i]);
|
|
566
|
+
if (lineNorm.startsWith(contextNorm)) {
|
|
567
|
+
if (firstMatch === undefined) firstMatch = i;
|
|
568
|
+
matchCount++;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (matchCount > 0) {
|
|
572
|
+
return { index: firstMatch, confidence: 0.96, matchCount };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Pass 5: Substring match (file line contains context)
|
|
577
|
+
// First pass: find all substring matches (ignoring ratio)
|
|
578
|
+
// If exactly one match exists, accept it (uniqueness is sufficient)
|
|
579
|
+
// If multiple matches, apply ratio filter to disambiguate
|
|
580
|
+
if (contextNorm.length >= PARTIAL_MATCH_MIN_LENGTH) {
|
|
581
|
+
const allSubstringMatches: Array<{ index: number; ratio: number }> = [];
|
|
582
|
+
for (let i = startFrom; i < lines.length; i++) {
|
|
583
|
+
const lineNorm = normalizeForFuzzy(lines[i]);
|
|
584
|
+
if (lineNorm.includes(contextNorm)) {
|
|
585
|
+
const ratio = contextNorm.length / Math.max(1, lineNorm.length);
|
|
586
|
+
allSubstringMatches.push({ index: i, ratio });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// If exactly one substring match, accept it regardless of ratio
|
|
591
|
+
if (allSubstringMatches.length === 1) {
|
|
592
|
+
return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: 1 };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Multiple matches: filter by ratio to disambiguate
|
|
596
|
+
let firstMatch: number | undefined;
|
|
597
|
+
let matchCount = 0;
|
|
598
|
+
for (const match of allSubstringMatches) {
|
|
599
|
+
if (match.ratio >= PARTIAL_MATCH_MIN_RATIO) {
|
|
600
|
+
if (firstMatch === undefined) firstMatch = match.index;
|
|
601
|
+
matchCount++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (matchCount > 0) {
|
|
605
|
+
return { index: firstMatch, confidence: 0.94, matchCount };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// If we had substring matches but none passed ratio filter,
|
|
609
|
+
// return ambiguous result so caller knows matches exist
|
|
610
|
+
if (allSubstringMatches.length > 1) {
|
|
611
|
+
return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: allSubstringMatches.length };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Pass 6: Fuzzy match using similarity
|
|
616
|
+
let bestIndex: number | undefined;
|
|
617
|
+
let bestScore = 0;
|
|
618
|
+
let matchCount = 0;
|
|
619
|
+
|
|
620
|
+
for (let i = startFrom; i < lines.length; i++) {
|
|
621
|
+
const lineNorm = normalizeForFuzzy(lines[i]);
|
|
622
|
+
const score = similarity(lineNorm, contextNorm);
|
|
623
|
+
if (score >= CONTEXT_FUZZY_THRESHOLD) {
|
|
624
|
+
matchCount++;
|
|
625
|
+
}
|
|
626
|
+
if (score > bestScore) {
|
|
627
|
+
bestScore = score;
|
|
628
|
+
bestIndex = i;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) {
|
|
633
|
+
return { index: bestIndex, confidence: bestScore, matchCount };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) {
|
|
637
|
+
const withParen = trimmedContext.replace(/\(\)\s*$/u, "(");
|
|
638
|
+
const withoutParen = trimmedContext.replace(/\(\)\s*$/u, "");
|
|
639
|
+
const parenResult = findContextLine(lines, withParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
|
|
640
|
+
if (parenResult.index !== undefined || (parenResult.matchCount ?? 0) > 0) {
|
|
641
|
+
return parenResult;
|
|
642
|
+
}
|
|
643
|
+
return findContextLine(lines, withoutParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return { index: undefined, confidence: bestScore };
|
|
647
|
+
}
|