@oh-my-pi/pi-coding-agent 13.19.0 → 14.0.3

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 (205) hide show
  1. package/CHANGELOG.md +277 -2
  2. package/package.json +86 -20
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +91 -0
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +83 -125
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -5
  28. package/src/commit/agentic/index.ts +3 -4
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/validation.ts +1 -1
  31. package/src/commit/analysis/conventional.ts +4 -4
  32. package/src/commit/analysis/summary.ts +3 -3
  33. package/src/commit/changelog/generate.ts +4 -4
  34. package/src/commit/map-reduce/map-phase.ts +4 -4
  35. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  36. package/src/commit/pipeline.ts +3 -4
  37. package/src/config/model-registry.ts +17 -3
  38. package/src/config/prompt-templates.ts +44 -226
  39. package/src/config/resolve-config-value.ts +4 -2
  40. package/src/config/settings-schema.ts +54 -2
  41. package/src/config/settings.ts +25 -26
  42. package/src/dap/client.ts +674 -0
  43. package/src/dap/config.ts +150 -0
  44. package/src/dap/defaults.json +211 -0
  45. package/src/dap/index.ts +4 -0
  46. package/src/dap/session.ts +1255 -0
  47. package/src/dap/types.ts +600 -0
  48. package/src/debug/log-viewer.ts +3 -2
  49. package/src/discovery/builtin.ts +1 -2
  50. package/src/discovery/codex.ts +2 -2
  51. package/src/discovery/github.ts +2 -1
  52. package/src/discovery/helpers.ts +2 -2
  53. package/src/discovery/opencode.ts +2 -2
  54. package/src/edit/diff.ts +818 -0
  55. package/src/edit/index.ts +309 -0
  56. package/src/edit/line-hash.ts +67 -0
  57. package/src/edit/modes/chunk.ts +454 -0
  58. package/src/{patch → edit/modes}/hashline.ts +741 -361
  59. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  60. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  61. package/src/{patch → edit}/normalize.ts +97 -76
  62. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  63. package/src/exec/bash-executor.ts +4 -2
  64. package/src/exec/idle-timeout-watchdog.ts +126 -0
  65. package/src/exec/non-interactive-env.ts +5 -0
  66. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +2 -2
  67. package/src/extensibility/custom-commands/bundled/review/index.ts +36 -15
  68. package/src/extensibility/custom-commands/loader.ts +1 -2
  69. package/src/extensibility/custom-tools/loader.ts +34 -11
  70. package/src/extensibility/extensions/loader.ts +9 -4
  71. package/src/extensibility/extensions/runner.ts +24 -1
  72. package/src/extensibility/extensions/types.ts +1 -1
  73. package/src/extensibility/hooks/loader.ts +5 -6
  74. package/src/extensibility/hooks/types.ts +1 -1
  75. package/src/extensibility/plugins/doctor.ts +2 -1
  76. package/src/extensibility/slash-commands.ts +3 -7
  77. package/src/index.ts +2 -1
  78. package/src/internal-urls/docs-index.generated.ts +11 -11
  79. package/src/ipy/executor.ts +58 -17
  80. package/src/ipy/gateway-coordinator.ts +6 -4
  81. package/src/ipy/kernel.ts +45 -22
  82. package/src/ipy/runtime.ts +2 -2
  83. package/src/lsp/client.ts +7 -4
  84. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  85. package/src/lsp/config.ts +20 -4
  86. package/src/lsp/defaults.json +688 -154
  87. package/src/lsp/index.ts +234 -45
  88. package/src/lsp/lspmux.ts +2 -2
  89. package/src/lsp/startup-events.ts +13 -0
  90. package/src/lsp/types.ts +12 -1
  91. package/src/lsp/utils.ts +8 -1
  92. package/src/main.ts +102 -46
  93. package/src/memories/index.ts +4 -5
  94. package/src/modes/acp/acp-agent.ts +563 -163
  95. package/src/modes/acp/acp-event-mapper.ts +9 -1
  96. package/src/modes/acp/acp-mode.ts +4 -2
  97. package/src/modes/components/agent-dashboard.ts +3 -4
  98. package/src/modes/components/diff.ts +6 -7
  99. package/src/modes/components/read-tool-group.ts +6 -12
  100. package/src/modes/components/session-observer-overlay.ts +21 -12
  101. package/src/modes/components/settings-defs.ts +5 -0
  102. package/src/modes/components/tool-execution.ts +1 -1
  103. package/src/modes/components/welcome.ts +1 -1
  104. package/src/modes/controllers/btw-controller.ts +2 -2
  105. package/src/modes/controllers/command-controller.ts +3 -2
  106. package/src/modes/controllers/input-controller.ts +12 -8
  107. package/src/modes/index.ts +20 -2
  108. package/src/modes/interactive-mode.ts +94 -37
  109. package/src/modes/rpc/host-tools.ts +186 -0
  110. package/src/modes/rpc/rpc-client.ts +178 -13
  111. package/src/modes/rpc/rpc-mode.ts +73 -3
  112. package/src/modes/rpc/rpc-types.ts +53 -1
  113. package/src/modes/theme/theme.ts +80 -8
  114. package/src/modes/types.ts +2 -2
  115. package/src/prompts/review-request.md +6 -0
  116. package/src/prompts/system/system-prompt.md +2 -1
  117. package/src/prompts/tools/chunk-edit.md +223 -0
  118. package/src/prompts/tools/debug.md +43 -0
  119. package/src/prompts/tools/grep.md +3 -0
  120. package/src/prompts/tools/lsp.md +5 -5
  121. package/src/prompts/tools/read-chunk.md +17 -0
  122. package/src/prompts/tools/read.md +19 -5
  123. package/src/sdk.ts +190 -154
  124. package/src/secrets/obfuscator.ts +1 -1
  125. package/src/session/agent-session.ts +306 -256
  126. package/src/session/agent-storage.ts +12 -12
  127. package/src/session/compaction/branch-summarization.ts +3 -3
  128. package/src/session/compaction/compaction.ts +5 -6
  129. package/src/session/compaction/utils.ts +3 -3
  130. package/src/session/history-storage.ts +62 -19
  131. package/src/session/messages.ts +3 -3
  132. package/src/session/session-dump-format.ts +203 -0
  133. package/src/session/session-storage.ts +4 -2
  134. package/src/session/streaming-output.ts +1 -1
  135. package/src/session/tool-choice-queue.ts +213 -0
  136. package/src/slash-commands/builtin-registry.ts +56 -8
  137. package/src/ssh/connection-manager.ts +2 -2
  138. package/src/ssh/sshfs-mount.ts +5 -5
  139. package/src/stt/downloader.ts +4 -4
  140. package/src/stt/recorder.ts +4 -4
  141. package/src/stt/transcriber.ts +2 -2
  142. package/src/system-prompt.ts +21 -13
  143. package/src/task/agents.ts +5 -6
  144. package/src/task/commands.ts +2 -5
  145. package/src/task/executor.ts +4 -4
  146. package/src/task/index.ts +3 -4
  147. package/src/task/template.ts +2 -2
  148. package/src/task/worktree.ts +4 -4
  149. package/src/tools/ask.ts +2 -3
  150. package/src/tools/ast-edit.ts +7 -7
  151. package/src/tools/ast-grep.ts +7 -7
  152. package/src/tools/auto-generated-guard.ts +36 -41
  153. package/src/tools/await-tool.ts +2 -2
  154. package/src/tools/bash.ts +5 -23
  155. package/src/tools/browser.ts +4 -5
  156. package/src/tools/calculator.ts +2 -3
  157. package/src/tools/cancel-job.ts +2 -2
  158. package/src/tools/checkpoint.ts +3 -3
  159. package/src/tools/debug.ts +1007 -0
  160. package/src/tools/exit-plan-mode.ts +2 -3
  161. package/src/tools/fetch.ts +67 -3
  162. package/src/tools/find.ts +4 -5
  163. package/src/tools/fs-cache-invalidation.ts +5 -0
  164. package/src/tools/gemini-image.ts +13 -5
  165. package/src/tools/gh.ts +10 -11
  166. package/src/tools/grep.ts +57 -9
  167. package/src/tools/index.ts +44 -22
  168. package/src/tools/inspect-image.ts +4 -4
  169. package/src/tools/output-meta.ts +1 -1
  170. package/src/tools/python.ts +19 -6
  171. package/src/tools/read.ts +198 -67
  172. package/src/tools/render-mermaid.ts +2 -3
  173. package/src/tools/render-utils.ts +20 -6
  174. package/src/tools/renderers.ts +3 -1
  175. package/src/tools/report-tool-issue.ts +80 -0
  176. package/src/tools/resolve.ts +70 -39
  177. package/src/tools/search-tool-bm25.ts +2 -2
  178. package/src/tools/ssh.ts +2 -2
  179. package/src/tools/todo-write.ts +2 -2
  180. package/src/tools/tool-timeouts.ts +1 -0
  181. package/src/tools/write.ts +5 -6
  182. package/src/tui/tree-list.ts +3 -1
  183. package/src/utils/clipboard.ts +80 -0
  184. package/src/utils/commit-message-generator.ts +2 -3
  185. package/src/utils/edit-mode.ts +49 -0
  186. package/src/utils/file-display-mode.ts +6 -5
  187. package/src/utils/file-mentions.ts +8 -7
  188. package/src/utils/git.ts +4 -4
  189. package/src/utils/image-loading.ts +98 -0
  190. package/src/utils/title-generator.ts +2 -3
  191. package/src/utils/tools-manager.ts +6 -6
  192. package/src/web/scrapers/choosealicense.ts +1 -1
  193. package/src/web/search/index.ts +3 -3
  194. package/src/autoresearch/command-initialize.md +0 -34
  195. package/src/patch/diff.ts +0 -433
  196. package/src/patch/index.ts +0 -888
  197. package/src/patch/parser.ts +0 -532
  198. package/src/patch/types.ts +0 -292
  199. package/src/prompts/agents/oracle.md +0 -77
  200. package/src/tools/pending-action.ts +0 -49
  201. package/src/utils/child-process.ts +0 -88
  202. package/src/utils/frontmatter.ts +0 -117
  203. package/src/utils/image-input.ts +0 -274
  204. package/src/utils/mime.ts +0 -53
  205. package/src/utils/prompt-format.ts +0 -170
@@ -4,8 +4,152 @@
4
4
  * Provides both character-level and line-level fuzzy matching with progressive
5
5
  * fallback strategies for finding text in files.
6
6
  */
7
- import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize";
8
- import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceMatchStrategy, SequenceSearchResult } from "./types";
7
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
+ import { isEnoent } from "@oh-my-pi/pi-utils";
9
+ import { type Static, Type } from "@sinclair/typebox";
10
+ import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
11
+ import type { ToolSession } from "../../tools";
12
+ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
13
+ import { outputMeta } from "../../tools/output-meta";
14
+ import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
15
+ import { generateDiffString, replaceText } from "../diff";
16
+ import {
17
+ countLeadingWhitespace,
18
+ detectLineEnding,
19
+ normalizeForFuzzy,
20
+ normalizeToLF,
21
+ normalizeUnicode,
22
+ restoreLineEndings,
23
+ stripBom,
24
+ } from "../normalize";
25
+ import type { EditToolDetails, LspBatchRequest } from "../renderer";
26
+
27
+ export interface FuzzyMatch {
28
+ actualText: string;
29
+ startIndex: number;
30
+ startLine: number;
31
+ confidence: number;
32
+ }
33
+
34
+ export interface MatchOutcome {
35
+ match?: FuzzyMatch;
36
+ closest?: FuzzyMatch;
37
+ occurrences?: number;
38
+ occurrenceLines?: number[];
39
+ occurrencePreviews?: string[];
40
+ fuzzyMatches?: number;
41
+ dominantFuzzy?: boolean;
42
+ }
43
+
44
+ export type SequenceMatchStrategy =
45
+ | "exact"
46
+ | "trim-trailing"
47
+ | "trim"
48
+ | "comment-prefix"
49
+ | "unicode"
50
+ | "prefix"
51
+ | "substring"
52
+ | "fuzzy"
53
+ | "fuzzy-dominant"
54
+ | "character";
55
+
56
+ export interface SequenceSearchResult {
57
+ index: number | undefined;
58
+ confidence: number;
59
+ matchCount?: number;
60
+ matchIndices?: number[];
61
+ strategy?: SequenceMatchStrategy;
62
+ }
63
+
64
+ export type ContextMatchStrategy = "exact" | "trim" | "unicode" | "prefix" | "substring" | "fuzzy";
65
+
66
+ export interface ContextLineResult {
67
+ index: number | undefined;
68
+ confidence: number;
69
+ matchCount?: number;
70
+ matchIndices?: number[];
71
+ strategy?: ContextMatchStrategy;
72
+ }
73
+
74
+ export class EditMatchError extends Error {
75
+ constructor(
76
+ readonly path: string,
77
+ readonly searchText: string,
78
+ readonly closest: FuzzyMatch | undefined,
79
+ readonly options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number },
80
+ ) {
81
+ super(EditMatchError.formatMessage(path, searchText, closest, options));
82
+ this.name = "EditMatchError";
83
+ }
84
+
85
+ static formatMessage(
86
+ path: string,
87
+ searchText: string,
88
+ closest: FuzzyMatch | undefined,
89
+ options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number },
90
+ ): string {
91
+ if (!closest) {
92
+ return options.allowFuzzy
93
+ ? `Could not find a close enough match in ${path}.`
94
+ : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`;
95
+ }
96
+
97
+ const similarity = Math.round(closest.confidence * 100);
98
+ const searchLines = searchText.split("\n");
99
+ const actualLines = closest.actualText.split("\n");
100
+ const { oldLine, newLine } = findFirstDifferentLine(searchLines, actualLines);
101
+ const thresholdPercent = Math.round(options.threshold * 100);
102
+
103
+ const hint = options.allowFuzzy
104
+ ? options.fuzzyMatches && options.fuzzyMatches > 1
105
+ ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
106
+ : `Closest match was below the ${thresholdPercent}% similarity threshold.`
107
+ : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
108
+
109
+ return [
110
+ options.allowFuzzy
111
+ ? `Could not find a close enough match in ${path}.`
112
+ : `Could not find the exact text in ${path}.`,
113
+ ``,
114
+ `Closest match (${similarity}% similar) at line ${closest.startLine}:`,
115
+ ` - ${oldLine}`,
116
+ ` + ${newLine}`,
117
+ hint,
118
+ ].join("\n");
119
+ }
120
+ }
121
+
122
+ function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLine: string; newLine: string } {
123
+ const max = Math.max(oldLines.length, newLines.length);
124
+ for (let i = 0; i < max; i++) {
125
+ const oldLine = oldLines[i] ?? "";
126
+ const newLine = newLines[i] ?? "";
127
+ if (oldLine !== newLine) {
128
+ return { oldLine, newLine };
129
+ }
130
+ }
131
+ return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" };
132
+ }
133
+
134
+ function formatOccurrenceError(path: string, matchOutcome: MatchOutcome): string {
135
+ const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
136
+ const moreMsg =
137
+ matchOutcome.occurrences && matchOutcome.occurrences > MAX_RECORDED_MATCHES
138
+ ? ` (showing first ${MAX_RECORDED_MATCHES} of ${matchOutcome.occurrences})`
139
+ : "";
140
+ return `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`;
141
+ }
142
+
143
+ async function readReplaceFileContent(absolutePath: string, path: string): Promise<string> {
144
+ try {
145
+ return await Bun.file(absolutePath).text();
146
+ } catch (error) {
147
+ if (isEnoent(error)) {
148
+ throw new Error(`File not found: ${path}`);
149
+ }
150
+ throw error;
151
+ }
152
+ }
9
153
 
10
154
  // ═══════════════════════════════════════════════════════════════════════════
11
155
  // Constants
@@ -35,6 +179,135 @@ const OCCURRENCE_PREVIEW_CONTEXT = 5;
35
179
  /** Maximum line length for ambiguous match previews */
36
180
  const OCCURRENCE_PREVIEW_MAX_LEN = 80;
37
181
 
182
+ /** Maximum number of match indices or previews to retain for diagnostics */
183
+ const MAX_RECORDED_MATCHES = 5;
184
+
185
+ /** Minimum confidence for a dominant fuzzy match to be auto-selected */
186
+ const DOMINANT_FUZZY_MIN_CONFIDENCE = 0.97;
187
+
188
+ /** Minimum score gap between the best and second-best fuzzy matches */
189
+ const DOMINANT_FUZZY_DELTA = 0.08;
190
+
191
+ interface IndexedMatches {
192
+ firstMatch: number | undefined;
193
+ matchCount: number;
194
+ matchIndices: number[];
195
+ }
196
+
197
+ interface PreviewWindowOptions {
198
+ context: number;
199
+ maxLen: number;
200
+ }
201
+
202
+ function collectIndexedMatches(
203
+ start: number,
204
+ endInclusive: number,
205
+ predicate: (index: number) => boolean,
206
+ ): IndexedMatches {
207
+ let firstMatch: number | undefined;
208
+ let matchCount = 0;
209
+ const matchIndices: number[] = [];
210
+
211
+ for (let index = start; index <= endInclusive; index++) {
212
+ if (!predicate(index)) continue;
213
+ if (firstMatch === undefined) {
214
+ firstMatch = index;
215
+ }
216
+ matchCount++;
217
+ if (matchIndices.length < MAX_RECORDED_MATCHES) {
218
+ matchIndices.push(index);
219
+ }
220
+ }
221
+
222
+ return { firstMatch, matchCount, matchIndices };
223
+ }
224
+
225
+ function toSingleMatchResult<TStrategy extends SequenceMatchStrategy | ContextMatchStrategy>(
226
+ matches: IndexedMatches,
227
+ confidence: number,
228
+ strategy: TStrategy,
229
+ ): { index: number; confidence: number; strategy: TStrategy } | undefined {
230
+ if (matches.firstMatch === undefined) {
231
+ return undefined;
232
+ }
233
+ return {
234
+ index: matches.firstMatch,
235
+ confidence,
236
+ strategy,
237
+ };
238
+ }
239
+
240
+ function toAmbiguousMatchResult<TStrategy extends SequenceMatchStrategy | ContextMatchStrategy>(
241
+ matches: IndexedMatches,
242
+ confidence: number,
243
+ strategy: TStrategy,
244
+ ): { index: number; confidence: number; matchCount: number; matchIndices: number[]; strategy: TStrategy } | undefined {
245
+ if (matches.firstMatch === undefined) {
246
+ return undefined;
247
+ }
248
+ return {
249
+ index: matches.firstMatch,
250
+ confidence,
251
+ matchCount: matches.matchCount,
252
+ matchIndices: matches.matchIndices,
253
+ strategy,
254
+ };
255
+ }
256
+
257
+ function formatPreviewWindow(lines: string[], centerIndex: number, options: PreviewWindowOptions): string {
258
+ const start = Math.max(0, centerIndex - options.context);
259
+ const end = Math.min(lines.length, centerIndex + options.context + 1);
260
+ return lines
261
+ .slice(start, end)
262
+ .map((line, index) => {
263
+ const num = start + index + 1;
264
+ const truncated = line.length > options.maxLen ? `${line.slice(0, options.maxLen - 1)}…` : line;
265
+ return ` ${num} | ${truncated}`;
266
+ })
267
+ .join("\n");
268
+ }
269
+
270
+ function findExactMatchOutcome(content: string, target: string): MatchOutcome | undefined {
271
+ const exactIndex = content.indexOf(target);
272
+ if (exactIndex === -1) {
273
+ return undefined;
274
+ }
275
+
276
+ const occurrences = content.split(target).length - 1;
277
+ if (occurrences > 1) {
278
+ const contentLines = content.split("\n");
279
+ const occurrenceLines: number[] = [];
280
+ const occurrencePreviews: string[] = [];
281
+ let searchStart = 0;
282
+
283
+ for (let i = 0; i < MAX_RECORDED_MATCHES; i++) {
284
+ const idx = content.indexOf(target, searchStart);
285
+ if (idx === -1) break;
286
+ const lineNumber = content.slice(0, idx).split("\n").length;
287
+ occurrenceLines.push(lineNumber);
288
+ occurrencePreviews.push(
289
+ formatPreviewWindow(contentLines, lineNumber - 1, {
290
+ context: OCCURRENCE_PREVIEW_CONTEXT,
291
+ maxLen: OCCURRENCE_PREVIEW_MAX_LEN,
292
+ }),
293
+ );
294
+ searchStart = idx + 1;
295
+ }
296
+
297
+ return { occurrences, occurrenceLines, occurrencePreviews };
298
+ }
299
+
300
+ const startLine = content.slice(0, exactIndex).split("\n").length;
301
+ return {
302
+ match: {
303
+ actualText: target,
304
+ startIndex: exactIndex,
305
+ startLine,
306
+ confidence: 1,
307
+ },
308
+ };
309
+ }
310
+
38
311
  // ═══════════════════════════════════════════════════════════════════════════
39
312
  // Core Algorithms
40
313
  // ═══════════════════════════════════════════════════════════════════════════
@@ -220,44 +493,9 @@ export function findMatch(
220
493
  return {};
221
494
  }
222
495
 
223
- // Try exact match first
224
- const exactIndex = content.indexOf(target);
225
- if (exactIndex !== -1) {
226
- const occurrences = content.split(target).length - 1;
227
- if (occurrences > 1) {
228
- // Find line numbers and previews for each occurrence (up to 5)
229
- const contentLines = content.split("\n");
230
- const occurrenceLines: number[] = [];
231
- const occurrencePreviews: string[] = [];
232
- let searchStart = 0;
233
- for (let i = 0; i < 5; i++) {
234
- const idx = content.indexOf(target, searchStart);
235
- if (idx === -1) break;
236
- const lineNumber = content.slice(0, idx).split("\n").length;
237
- occurrenceLines.push(lineNumber);
238
- const start = Math.max(0, lineNumber - 1 - OCCURRENCE_PREVIEW_CONTEXT);
239
- const end = Math.min(contentLines.length, lineNumber + OCCURRENCE_PREVIEW_CONTEXT + 1);
240
- const previewLines = contentLines.slice(start, end);
241
- const preview = previewLines
242
- .map((line, idx) => {
243
- const num = start + idx + 1;
244
- return ` ${num} | ${line.length > OCCURRENCE_PREVIEW_MAX_LEN ? `${line.slice(0, OCCURRENCE_PREVIEW_MAX_LEN - 1)}…` : line}`;
245
- })
246
- .join("\n");
247
- occurrencePreviews.push(preview);
248
- searchStart = idx + 1;
249
- }
250
- return { occurrences, occurrenceLines, occurrencePreviews };
251
- }
252
- const startLine = content.slice(0, exactIndex).split("\n").length;
253
- return {
254
- match: {
255
- actualText: target,
256
- startIndex: exactIndex,
257
- startLine,
258
- confidence: 1,
259
- },
260
- };
496
+ const exactMatch = findExactMatchOutcome(content, target);
497
+ if (exactMatch) {
498
+ return exactMatch;
261
499
  }
262
500
 
263
501
  // Try fuzzy match
@@ -272,12 +510,10 @@ export function findMatch(
272
510
  if (aboveThresholdCount === 1) {
273
511
  return { match: best, closest: best };
274
512
  }
275
- const dominantDelta = 0.08;
276
- const dominantMin = 0.97;
277
513
  if (
278
514
  aboveThresholdCount > 1 &&
279
- best.confidence >= dominantMin &&
280
- best.confidence - secondBestScore >= dominantDelta
515
+ best.confidence >= DOMINANT_FUZZY_MIN_CONFIDENCE &&
516
+ best.confidence - secondBestScore >= DOMINANT_FUZZY_DELTA
281
517
  ) {
282
518
  return { match: best, closest: best, fuzzyMatches: aboveThresholdCount, dominantFuzzy: true };
283
519
  }
@@ -389,38 +625,31 @@ export function seekSequence(
389
625
  const maxStart = lines.length - pattern.length;
390
626
 
391
627
  const runExactPasses = (from: number, to: number): SequenceSearchResult | undefined => {
392
- // Pass 1: Exact match
393
- for (let i = from; i <= to; i++) {
394
- if (matchesAt(lines, pattern, i, (a, b) => a === b)) {
395
- return { index: i, confidence: 1.0, strategy: "exact" };
396
- }
397
- }
398
-
399
- // Pass 2: Trailing whitespace stripped
400
- for (let i = from; i <= to; i++) {
401
- if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) {
402
- return { index: i, confidence: 0.99, strategy: "trim-trailing" };
403
- }
404
- }
405
-
406
- // Pass 3: Both leading and trailing whitespace stripped
407
- for (let i = from; i <= to; i++) {
408
- if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) {
409
- return { index: i, confidence: 0.98, strategy: "trim" };
410
- }
411
- }
412
-
413
- // Pass 3b: Comment-prefix normalized match
414
- for (let i = from; i <= to; i++) {
415
- if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) {
416
- return { index: i, confidence: 0.975, strategy: "comment-prefix" };
417
- }
418
- }
628
+ const comparisonPasses: Array<{
629
+ compare: (a: string, b: string) => boolean;
630
+ confidence: number;
631
+ strategy: SequenceMatchStrategy;
632
+ }> = [
633
+ { compare: (a, b) => a === b, confidence: 1.0, strategy: "exact" },
634
+ { compare: (a, b) => a.trimEnd() === b.trimEnd(), confidence: 0.99, strategy: "trim-trailing" },
635
+ { compare: (a, b) => a.trim() === b.trim(), confidence: 0.98, strategy: "trim" },
636
+ {
637
+ compare: (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b),
638
+ confidence: 0.975,
639
+ strategy: "comment-prefix",
640
+ },
641
+ {
642
+ compare: (a, b) => normalizeUnicode(a) === normalizeUnicode(b),
643
+ confidence: 0.97,
644
+ strategy: "unicode",
645
+ },
646
+ ];
419
647
 
420
- // Pass 4: Normalize unicode punctuation
421
- for (let i = from; i <= to; i++) {
422
- if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) {
423
- return { index: i, confidence: 0.97, strategy: "unicode" };
648
+ for (const pass of comparisonPasses) {
649
+ const matches = collectIndexedMatches(from, to, i => matchesAt(lines, pattern, i, pass.compare));
650
+ const result = toSingleMatchResult(matches, pass.confidence, pass.strategy);
651
+ if (result) {
652
+ return result;
424
653
  }
425
654
  }
426
655
 
@@ -428,37 +657,20 @@ export function seekSequence(
428
657
  return undefined;
429
658
  }
430
659
 
431
- // Pass 5: Partial line prefix match (track all matches for ambiguity detection)
432
- {
433
- let firstMatch: number | undefined;
434
- let matchCount = 0;
435
- const matchIndices: number[] = [];
436
- for (let i = from; i <= to; i++) {
437
- if (matchesAt(lines, pattern, i, lineStartsWithPattern)) {
438
- if (firstMatch === undefined) firstMatch = i;
439
- matchCount++;
440
- if (matchIndices.length < 5) matchIndices.push(i);
441
- }
442
- }
443
- if (matchCount > 0) {
444
- return { index: firstMatch, confidence: 0.965, matchCount, matchIndices, strategy: "prefix" };
445
- }
446
- }
447
-
448
- // Pass 6: Partial line substring match (track all matches for ambiguity detection)
449
- {
450
- let firstMatch: number | undefined;
451
- let matchCount = 0;
452
- const matchIndices: number[] = [];
453
- for (let i = from; i <= to; i++) {
454
- if (matchesAt(lines, pattern, i, lineIncludesPattern)) {
455
- if (firstMatch === undefined) firstMatch = i;
456
- matchCount++;
457
- if (matchIndices.length < 5) matchIndices.push(i);
458
- }
459
- }
460
- if (matchCount > 0) {
461
- return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" };
660
+ const partialPasses: Array<{
661
+ compare: (line: string, patternLine: string) => boolean;
662
+ confidence: number;
663
+ strategy: SequenceMatchStrategy;
664
+ }> = [
665
+ { compare: lineStartsWithPattern, confidence: 0.965, strategy: "prefix" },
666
+ { compare: lineIncludesPattern, confidence: 0.94, strategy: "substring" },
667
+ ];
668
+
669
+ for (const pass of partialPasses) {
670
+ const matches = collectIndexedMatches(from, to, i => matchesAt(lines, pattern, i, pass.compare));
671
+ const result = toAmbiguousMatchResult(matches, pass.confidence, pass.strategy);
672
+ if (result) {
673
+ return result;
462
674
  }
463
675
  }
464
676
 
@@ -482,34 +694,26 @@ export function seekSequence(
482
694
  }
483
695
 
484
696
  // Pass 7: Fuzzy matching - find best match above threshold
485
- let bestIndex: number | undefined;
486
697
  let bestScore = 0;
487
698
  let secondBestScore = 0;
488
- let matchCount = 0;
489
- const matchIndices: number[] = [];
490
-
491
- for (let i = searchStart; i <= maxStart; i++) {
492
- const score = fuzzyScoreAt(lines, pattern, i);
493
- if (score >= SEQUENCE_FUZZY_THRESHOLD) {
494
- matchCount++;
495
- if (matchIndices.length < 5) matchIndices.push(i);
496
- }
497
- if (score > bestScore) {
498
- secondBestScore = bestScore;
499
- bestScore = score;
500
- bestIndex = i;
501
- } else if (score > secondBestScore) {
502
- secondBestScore = score;
503
- }
504
- }
699
+ let bestIndex: number | undefined;
700
+ const fuzzyMatches: IndexedMatches = {
701
+ firstMatch: undefined,
702
+ matchCount: 0,
703
+ matchIndices: [],
704
+ };
505
705
 
506
- // Also search from start if eof mode started from end
507
- if (eof && searchStart > start) {
508
- for (let i = start; i < searchStart; i++) {
706
+ const scoreFuzzyRange = (from: number, to: number): void => {
707
+ for (let i = from; i <= to; i++) {
509
708
  const score = fuzzyScoreAt(lines, pattern, i);
510
709
  if (score >= SEQUENCE_FUZZY_THRESHOLD) {
511
- matchCount++;
512
- if (matchIndices.length < 5) matchIndices.push(i);
710
+ if (fuzzyMatches.firstMatch === undefined) {
711
+ fuzzyMatches.firstMatch = i;
712
+ }
713
+ fuzzyMatches.matchCount++;
714
+ if (fuzzyMatches.matchIndices.length < MAX_RECORDED_MATCHES) {
715
+ fuzzyMatches.matchIndices.push(i);
716
+ }
513
717
  }
514
718
  if (score > bestScore) {
515
719
  secondBestScore = bestScore;
@@ -519,21 +723,36 @@ export function seekSequence(
519
723
  secondBestScore = score;
520
724
  }
521
725
  }
726
+ };
727
+
728
+ scoreFuzzyRange(searchStart, maxStart);
729
+
730
+ // Also search from start if eof mode started from end
731
+ if (eof && searchStart > start) {
732
+ scoreFuzzyRange(start, searchStart - 1);
522
733
  }
523
734
 
524
735
  if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) {
525
- const dominantDelta = 0.08;
526
- const dominantMin = 0.97;
527
- if (matchCount > 1 && bestScore >= dominantMin && bestScore - secondBestScore >= dominantDelta) {
736
+ if (
737
+ fuzzyMatches.matchCount > 1 &&
738
+ bestScore >= DOMINANT_FUZZY_MIN_CONFIDENCE &&
739
+ bestScore - secondBestScore >= DOMINANT_FUZZY_DELTA
740
+ ) {
528
741
  return {
529
742
  index: bestIndex,
530
743
  confidence: bestScore,
531
744
  matchCount: 1,
532
- matchIndices,
745
+ matchIndices: fuzzyMatches.matchIndices,
533
746
  strategy: "fuzzy-dominant",
534
747
  };
535
748
  }
536
- return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" };
749
+ return {
750
+ index: bestIndex,
751
+ confidence: bestScore,
752
+ matchCount: fuzzyMatches.matchCount,
753
+ matchIndices: fuzzyMatches.matchIndices,
754
+ strategy: "fuzzy",
755
+ };
537
756
  }
538
757
 
539
758
  // Pass 8: Character-based fuzzy matching via findMatch
@@ -620,56 +839,34 @@ export function findContextLine(
620
839
  const allowFuzzy = options?.allowFuzzy ?? true;
621
840
  const trimmedContext = context.trim();
622
841
 
623
- // Pass 1: Exact line match
624
- {
625
- let firstMatch: number | undefined;
626
- let matchCount = 0;
627
- const matchIndices: number[] = [];
628
- for (let i = startFrom; i < lines.length; i++) {
629
- if (lines[i] === context) {
630
- if (firstMatch === undefined) firstMatch = i;
631
- matchCount++;
632
- if (matchIndices.length < 5) matchIndices.push(i);
633
- }
634
- }
635
- if (matchCount > 0) {
636
- return { index: firstMatch, confidence: 1.0, matchCount, matchIndices, strategy: "exact" };
637
- }
638
- }
639
-
640
- // Pass 2: Trimmed match
641
- {
642
- let firstMatch: number | undefined;
643
- let matchCount = 0;
644
- const matchIndices: number[] = [];
645
- for (let i = startFrom; i < lines.length; i++) {
646
- if (lines[i].trim() === trimmedContext) {
647
- if (firstMatch === undefined) firstMatch = i;
648
- matchCount++;
649
- if (matchIndices.length < 5) matchIndices.push(i);
650
- }
651
- }
652
- if (matchCount > 0) {
653
- return { index: firstMatch, confidence: 0.99, matchCount, matchIndices, strategy: "trim" };
842
+ const endIndex = lines.length - 1;
843
+ const exactPasses: Array<{
844
+ confidence: number;
845
+ strategy: ContextMatchStrategy;
846
+ predicate: (index: number) => boolean;
847
+ }> = [
848
+ { confidence: 1.0, strategy: "exact", predicate: i => lines[i] === context },
849
+ { confidence: 0.99, strategy: "trim", predicate: i => lines[i].trim() === trimmedContext },
850
+ ];
851
+
852
+ for (const pass of exactPasses) {
853
+ const matches = collectIndexedMatches(startFrom, endIndex, pass.predicate);
854
+ const result = toAmbiguousMatchResult(matches, pass.confidence, pass.strategy);
855
+ if (result) {
856
+ return result;
654
857
  }
655
858
  }
656
859
 
657
860
  // Pass 3: Unicode normalization match
658
861
  const normalizedContext = normalizeUnicode(context);
659
- {
660
- let firstMatch: number | undefined;
661
- let matchCount = 0;
662
- const matchIndices: number[] = [];
663
- for (let i = startFrom; i < lines.length; i++) {
664
- if (normalizeUnicode(lines[i]) === normalizedContext) {
665
- if (firstMatch === undefined) firstMatch = i;
666
- matchCount++;
667
- if (matchIndices.length < 5) matchIndices.push(i);
668
- }
669
- }
670
- if (matchCount > 0) {
671
- return { index: firstMatch, confidence: 0.98, matchCount, matchIndices, strategy: "unicode" };
672
- }
862
+ const unicodeMatches = collectIndexedMatches(
863
+ startFrom,
864
+ endIndex,
865
+ i => normalizeUnicode(lines[i]) === normalizedContext,
866
+ );
867
+ const unicodeResult = toAmbiguousMatchResult(unicodeMatches, 0.98, "unicode");
868
+ if (unicodeResult) {
869
+ return unicodeResult;
673
870
  }
674
871
 
675
872
  if (!allowFuzzy) {
@@ -679,19 +876,12 @@ export function findContextLine(
679
876
  // Pass 4: Prefix match (file line starts with context)
680
877
  const contextNorm = normalizeForFuzzy(context);
681
878
  if (contextNorm.length > 0) {
682
- let firstMatch: number | undefined;
683
- let matchCount = 0;
684
- const matchIndices: number[] = [];
685
- for (let i = startFrom; i < lines.length; i++) {
686
- const lineNorm = normalizeForFuzzy(lines[i]);
687
- if (lineNorm.startsWith(contextNorm)) {
688
- if (firstMatch === undefined) firstMatch = i;
689
- matchCount++;
690
- if (matchIndices.length < 5) matchIndices.push(i);
691
- }
692
- }
693
- if (matchCount > 0) {
694
- return { index: firstMatch, confidence: 0.96, matchCount, matchIndices, strategy: "prefix" };
879
+ const prefixMatches = collectIndexedMatches(startFrom, endIndex, i =>
880
+ normalizeForFuzzy(lines[i]).startsWith(contextNorm),
881
+ );
882
+ const prefixResult = toAmbiguousMatchResult(prefixMatches, 0.96, "prefix");
883
+ if (prefixResult) {
884
+ return prefixResult;
695
885
  }
696
886
  }
697
887
 
@@ -750,15 +940,23 @@ export function findContextLine(
750
940
  // Pass 6: Fuzzy match using similarity
751
941
  let bestIndex: number | undefined;
752
942
  let bestScore = 0;
753
- let matchCount = 0;
754
- const matchIndices: number[] = [];
943
+ const fuzzyMatches: IndexedMatches = {
944
+ firstMatch: undefined,
945
+ matchCount: 0,
946
+ matchIndices: [],
947
+ };
755
948
 
756
949
  for (let i = startFrom; i < lines.length; i++) {
757
950
  const lineNorm = normalizeForFuzzy(lines[i]);
758
951
  const score = similarity(lineNorm, contextNorm);
759
952
  if (score >= CONTEXT_FUZZY_THRESHOLD) {
760
- matchCount++;
761
- if (matchIndices.length < 5) matchIndices.push(i);
953
+ if (fuzzyMatches.firstMatch === undefined) {
954
+ fuzzyMatches.firstMatch = i;
955
+ }
956
+ fuzzyMatches.matchCount++;
957
+ if (fuzzyMatches.matchIndices.length < MAX_RECORDED_MATCHES) {
958
+ fuzzyMatches.matchIndices.push(i);
959
+ }
762
960
  }
763
961
  if (score > bestScore) {
764
962
  bestScore = score;
@@ -767,7 +965,13 @@ export function findContextLine(
767
965
  }
768
966
 
769
967
  if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) {
770
- return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" };
968
+ return {
969
+ index: bestIndex,
970
+ confidence: bestScore,
971
+ matchCount: fuzzyMatches.matchCount,
972
+ matchIndices: fuzzyMatches.matchIndices,
973
+ strategy: "fuzzy",
974
+ };
771
975
  }
772
976
 
773
977
  if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) {
@@ -782,3 +986,121 @@ export function findContextLine(
782
986
 
783
987
  return { index: undefined, confidence: bestScore };
784
988
  }
989
+
990
+ export const replaceEditSchema = Type.Object({
991
+ path: Type.String({ description: "File path (relative or absolute)" }),
992
+ old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
993
+ new_text: Type.String({ description: "Replacement text" }),
994
+ all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
995
+ });
996
+
997
+ export type ReplaceParams = Static<typeof replaceEditSchema>;
998
+
999
+ interface ExecuteReplaceModeOptions {
1000
+ session: ToolSession;
1001
+ params: ReplaceParams;
1002
+ signal?: AbortSignal;
1003
+ batchRequest?: LspBatchRequest;
1004
+ allowFuzzy: boolean;
1005
+ fuzzyThreshold: number;
1006
+ writethrough: WritethroughCallback;
1007
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1008
+ }
1009
+
1010
+ export function isReplaceParams(params: unknown): params is ReplaceParams {
1011
+ return typeof params === "object" && params !== null && "old_text" in params && "new_text" in params;
1012
+ }
1013
+
1014
+ export async function executeReplaceMode(
1015
+ options: ExecuteReplaceModeOptions,
1016
+ ): Promise<AgentToolResult<EditToolDetails, typeof replaceEditSchema>> {
1017
+ const {
1018
+ session,
1019
+ params,
1020
+ signal,
1021
+ batchRequest,
1022
+ allowFuzzy,
1023
+ fuzzyThreshold,
1024
+ writethrough,
1025
+ beginDeferredDiagnosticsForPath,
1026
+ } = options;
1027
+ const { path, old_text, new_text, all } = params;
1028
+
1029
+ enforcePlanModeWrite(session, path);
1030
+
1031
+ if (path.endsWith(".ipynb")) {
1032
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1033
+ }
1034
+
1035
+ if (old_text.length === 0) {
1036
+ throw new Error("old_text must not be empty.");
1037
+ }
1038
+
1039
+ const absolutePath = resolvePlanPath(session, path);
1040
+ const rawContent = await readReplaceFileContent(absolutePath, path);
1041
+ const { bom, text: content } = stripBom(rawContent);
1042
+ const originalEnding = detectLineEnding(content);
1043
+ const normalizedContent = normalizeToLF(content);
1044
+ const normalizedOldText = normalizeToLF(old_text);
1045
+ const normalizedNewText = normalizeToLF(new_text);
1046
+
1047
+ const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
1048
+ fuzzy: allowFuzzy,
1049
+ all: all ?? false,
1050
+ threshold: fuzzyThreshold,
1051
+ });
1052
+
1053
+ if (result.count === 0) {
1054
+ const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
1055
+ allowFuzzy,
1056
+ threshold: fuzzyThreshold,
1057
+ });
1058
+
1059
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
1060
+ throw new Error(formatOccurrenceError(path, matchOutcome));
1061
+ }
1062
+
1063
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
1064
+ allowFuzzy,
1065
+ threshold: fuzzyThreshold,
1066
+ fuzzyMatches: matchOutcome.fuzzyMatches,
1067
+ });
1068
+ }
1069
+
1070
+ if (normalizedContent === result.content) {
1071
+ throw new Error(
1072
+ `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
1073
+ );
1074
+ }
1075
+
1076
+ const finalContent = bom + restoreLineEndings(result.content, originalEnding);
1077
+ const diagnostics = await writethrough(
1078
+ absolutePath,
1079
+ finalContent,
1080
+ signal,
1081
+ Bun.file(absolutePath),
1082
+ batchRequest,
1083
+ dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1084
+ );
1085
+ invalidateFsScanAfterWrite(absolutePath);
1086
+
1087
+ const diffResult = generateDiffString(normalizedContent, result.content);
1088
+ const resultText =
1089
+ result.count > 1
1090
+ ? `Successfully replaced ${result.count} occurrences in ${path}.`
1091
+ : `Successfully replaced text in ${path}.`;
1092
+
1093
+ const meta = outputMeta()
1094
+ .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1095
+ .get();
1096
+
1097
+ return {
1098
+ content: [{ type: "text", text: resultText }],
1099
+ details: {
1100
+ diff: diffResult.diff,
1101
+ firstChangedLine: diffResult.firstChangedLine,
1102
+ diagnostics,
1103
+ meta,
1104
+ },
1105
+ };
1106
+ }