@oh-my-pi/pi-coding-agent 6.1.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +25 -25
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +824 -639
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +89 -41
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -326
@@ -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
+ }