@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,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit tool module.
|
|
3
|
+
*
|
|
4
|
+
* Supports two modes:
|
|
5
|
+
* - Replace mode (default): oldText/newText replacement with fuzzy matching
|
|
6
|
+
* - Patch mode: structured diff format with explicit operation type
|
|
7
|
+
*
|
|
8
|
+
* The mode is determined by the `edit.patchMode` setting.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
13
|
+
import { StringEnum } from "@oh-my-pi/pi-ai";
|
|
14
|
+
import { Type } from "@sinclair/typebox";
|
|
15
|
+
import patchDescription from "../../../prompts/tools/patch.md" with { type: "text" };
|
|
16
|
+
import replaceDescription from "../../../prompts/tools/replace.md" with { type: "text" };
|
|
17
|
+
import { renderPromptTemplate } from "../../prompt-templates";
|
|
18
|
+
import type { ToolSession } from "../index";
|
|
19
|
+
import {
|
|
20
|
+
createLspWritethrough,
|
|
21
|
+
type FileDiagnosticsResult,
|
|
22
|
+
flushLspWritethroughBatch,
|
|
23
|
+
type WritethroughCallback,
|
|
24
|
+
writethroughNoop,
|
|
25
|
+
} from "../lsp/index";
|
|
26
|
+
import { resolveToCwd } from "../path-utils";
|
|
27
|
+
import { applyPatch } from "./applicator";
|
|
28
|
+
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
|
|
29
|
+
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
|
|
30
|
+
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
31
|
+
import { buildNormativeUpdateInput } from "./normative";
|
|
32
|
+
import { type EditToolDetails, getLspBatchRequest } from "./shared";
|
|
33
|
+
// Internal imports
|
|
34
|
+
import type { FileSystem, Operation, PatchInput } from "./types";
|
|
35
|
+
import { EditMatchError } from "./types";
|
|
36
|
+
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
// Re-exports
|
|
39
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
// Application
|
|
42
|
+
export { applyPatch, defaultFileSystem, previewPatch } from "./applicator";
|
|
43
|
+
// Diff generation
|
|
44
|
+
export { computeEditDiff, computePatchDiff, generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
|
|
45
|
+
|
|
46
|
+
// Fuzzy matching
|
|
47
|
+
export { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch as findEditMatch, findMatch, seekSequence } from "./fuzzy";
|
|
48
|
+
|
|
49
|
+
// Normalization
|
|
50
|
+
export {
|
|
51
|
+
adjustIndentation as adjustNewTextIndentation,
|
|
52
|
+
detectLineEnding,
|
|
53
|
+
normalizeToLF,
|
|
54
|
+
restoreLineEndings,
|
|
55
|
+
stripBom,
|
|
56
|
+
} from "./normalize";
|
|
57
|
+
|
|
58
|
+
// Parsing
|
|
59
|
+
export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } from "./parser";
|
|
60
|
+
export type { EditRenderContext, EditToolDetails } from "./shared";
|
|
61
|
+
// Rendering
|
|
62
|
+
export { editToolRenderer, getLspBatchRequest } from "./shared";
|
|
63
|
+
export type {
|
|
64
|
+
ApplyPatchOptions,
|
|
65
|
+
ApplyPatchResult,
|
|
66
|
+
ContextLineResult,
|
|
67
|
+
DiffError,
|
|
68
|
+
DiffError as EditDiffError,
|
|
69
|
+
DiffHunk,
|
|
70
|
+
DiffHunk as UpdateChunk,
|
|
71
|
+
DiffHunk as UpdateFileChunk,
|
|
72
|
+
DiffResult,
|
|
73
|
+
DiffResult as EditDiffResult,
|
|
74
|
+
FileChange,
|
|
75
|
+
FileSystem,
|
|
76
|
+
FuzzyMatch as EditMatch,
|
|
77
|
+
FuzzyMatch,
|
|
78
|
+
MatchOutcome as EditMatchOutcome,
|
|
79
|
+
MatchOutcome,
|
|
80
|
+
Operation,
|
|
81
|
+
PatchInput,
|
|
82
|
+
SequenceSearchResult,
|
|
83
|
+
} from "./types";
|
|
84
|
+
// Types
|
|
85
|
+
// Legacy aliases for backwards compatibility
|
|
86
|
+
export { ApplyPatchError, EditMatchError, ParseError } from "./types";
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
89
|
+
// Schemas
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
const replaceEditSchema = Type.Object({
|
|
93
|
+
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
94
|
+
oldText: Type.String({
|
|
95
|
+
description: "Text to find and replace (high-confidence fuzzy matching for whitespace/indentation is always on)",
|
|
96
|
+
}),
|
|
97
|
+
newText: Type.String({ description: "New text to replace the old text with" }),
|
|
98
|
+
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences instead of requiring unique match" })),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const patchEditSchema = Type.Object({
|
|
102
|
+
path: Type.String({ description: "Path to the file" }),
|
|
103
|
+
op: Type.Optional(
|
|
104
|
+
StringEnum(["create", "delete", "update"], {
|
|
105
|
+
description: "The operation to perform (Defaults to 'update')",
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
rename: Type.Optional(Type.String({ description: "New path, if moving" })),
|
|
109
|
+
diff: Type.Optional(
|
|
110
|
+
Type.String({
|
|
111
|
+
description: "Diff hunk(s) for update. Full content for create.",
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export type ReplaceParams = { path: string; oldText: string; newText: string; all?: boolean };
|
|
117
|
+
export type PatchParams = { path: string; op?: string; rename?: string; diff?: string };
|
|
118
|
+
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
// LSP FileSystem for patch mode
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
class LspFileSystem implements FileSystem {
|
|
124
|
+
private lastDiagnostics: FileDiagnosticsResult | undefined;
|
|
125
|
+
private fileCache: Record<string, Bun.BunFile> = {};
|
|
126
|
+
|
|
127
|
+
constructor(
|
|
128
|
+
private readonly writethrough: (
|
|
129
|
+
dst: string,
|
|
130
|
+
content: string,
|
|
131
|
+
signal?: AbortSignal,
|
|
132
|
+
file?: import("bun").BunFile,
|
|
133
|
+
batch?: { id: string; flush: boolean },
|
|
134
|
+
) => Promise<FileDiagnosticsResult | undefined>,
|
|
135
|
+
private readonly signal?: AbortSignal,
|
|
136
|
+
private readonly batchRequest?: { id: string; flush: boolean },
|
|
137
|
+
) {}
|
|
138
|
+
|
|
139
|
+
#getFile(path: string): Bun.BunFile {
|
|
140
|
+
if (this.fileCache[path]) {
|
|
141
|
+
return this.fileCache[path];
|
|
142
|
+
}
|
|
143
|
+
const file = Bun.file(path);
|
|
144
|
+
this.fileCache[path] = file;
|
|
145
|
+
return file;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async exists(path: string): Promise<boolean> {
|
|
149
|
+
return this.#getFile(path).exists();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async read(path: string): Promise<string> {
|
|
153
|
+
return this.#getFile(path).text();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async readBinary(path: string): Promise<Uint8Array> {
|
|
157
|
+
const buffer = await this.#getFile(path).arrayBuffer();
|
|
158
|
+
return new Uint8Array(buffer);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async write(path: string, content: string): Promise<void> {
|
|
162
|
+
const file = this.#getFile(path);
|
|
163
|
+
const result = await this.writethrough(path, content, this.signal, file, this.batchRequest);
|
|
164
|
+
if (result) {
|
|
165
|
+
this.lastDiagnostics = result;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async delete(path: string): Promise<void> {
|
|
170
|
+
await this.#getFile(path).unlink();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async mkdir(path: string): Promise<void> {
|
|
174
|
+
await mkdir(path, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
getDiagnostics(): FileDiagnosticsResult | undefined {
|
|
178
|
+
return this.lastDiagnostics;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
183
|
+
// Tool Class
|
|
184
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
type TInput = typeof replaceEditSchema | typeof patchEditSchema;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Edit tool implementation.
|
|
190
|
+
*
|
|
191
|
+
* Creates replace-mode or patch-mode behavior based on session settings.
|
|
192
|
+
*/
|
|
193
|
+
export class EditTool implements AgentTool<TInput> {
|
|
194
|
+
public readonly name = "edit";
|
|
195
|
+
public readonly label = "Edit";
|
|
196
|
+
public readonly description: string;
|
|
197
|
+
public readonly parameters: TInput;
|
|
198
|
+
|
|
199
|
+
private readonly session: ToolSession;
|
|
200
|
+
private readonly patchMode: boolean;
|
|
201
|
+
private readonly allowFuzzy: boolean;
|
|
202
|
+
private readonly fuzzyThreshold: number;
|
|
203
|
+
private readonly writethrough: WritethroughCallback;
|
|
204
|
+
|
|
205
|
+
constructor(session: ToolSession) {
|
|
206
|
+
this.session = session;
|
|
207
|
+
|
|
208
|
+
const {
|
|
209
|
+
OMP_EDIT_FUZZY: editFuzzy = "auto",
|
|
210
|
+
OMP_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto",
|
|
211
|
+
OMP_EDIT_VARIANT: editVariant = "auto",
|
|
212
|
+
} = process.env;
|
|
213
|
+
|
|
214
|
+
switch (editVariant) {
|
|
215
|
+
case "replace":
|
|
216
|
+
this.patchMode = false;
|
|
217
|
+
break;
|
|
218
|
+
case "patch":
|
|
219
|
+
this.patchMode = true;
|
|
220
|
+
break;
|
|
221
|
+
case "auto":
|
|
222
|
+
this.patchMode = session.settings?.getEditPatchMode?.() ?? true;
|
|
223
|
+
break;
|
|
224
|
+
default:
|
|
225
|
+
throw new Error(`Invalid OMP_EDIT_VARIANT: ${process.env.OMP_EDIT_VARIANT}`);
|
|
226
|
+
}
|
|
227
|
+
switch (editFuzzy) {
|
|
228
|
+
case "true":
|
|
229
|
+
case "1":
|
|
230
|
+
this.allowFuzzy = true;
|
|
231
|
+
break;
|
|
232
|
+
case "false":
|
|
233
|
+
case "0":
|
|
234
|
+
this.allowFuzzy = false;
|
|
235
|
+
break;
|
|
236
|
+
case "auto":
|
|
237
|
+
this.allowFuzzy = session.settings?.getEditFuzzyMatch() ?? true;
|
|
238
|
+
break;
|
|
239
|
+
default:
|
|
240
|
+
throw new Error(`Invalid OMP_EDIT_FUZZY: ${editFuzzy}`);
|
|
241
|
+
}
|
|
242
|
+
switch (editFuzzyThreshold) {
|
|
243
|
+
case "auto":
|
|
244
|
+
this.fuzzyThreshold = session.settings?.getEditFuzzyThreshold?.() ?? DEFAULT_FUZZY_THRESHOLD;
|
|
245
|
+
break;
|
|
246
|
+
default:
|
|
247
|
+
this.fuzzyThreshold = parseFloat(editFuzzyThreshold);
|
|
248
|
+
if (Number.isNaN(this.fuzzyThreshold) || this.fuzzyThreshold < 0 || this.fuzzyThreshold > 1) {
|
|
249
|
+
throw new Error(`Invalid OMP_EDIT_FUZZY_THRESHOLD: ${editFuzzyThreshold}`);
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const enableLsp = session.enableLsp ?? true;
|
|
255
|
+
const enableDiagnostics = enableLsp ? (session.settings?.getLspDiagnosticsOnEdit() ?? false) : false;
|
|
256
|
+
const enableFormat = enableLsp ? (session.settings?.getLspFormatOnWrite() ?? true) : false;
|
|
257
|
+
this.writethrough = enableLsp
|
|
258
|
+
? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
|
|
259
|
+
: writethroughNoop;
|
|
260
|
+
this.description = this.patchMode
|
|
261
|
+
? renderPromptTemplate(patchDescription)
|
|
262
|
+
: renderPromptTemplate(replaceDescription);
|
|
263
|
+
this.parameters = this.patchMode ? patchEditSchema : replaceEditSchema;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public async execute(
|
|
267
|
+
_toolCallId: string,
|
|
268
|
+
params: ReplaceParams | PatchParams,
|
|
269
|
+
signal?: AbortSignal,
|
|
270
|
+
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
|
271
|
+
context?: AgentToolContext,
|
|
272
|
+
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
|
273
|
+
const batchRequest = getLspBatchRequest(context?.toolCall);
|
|
274
|
+
|
|
275
|
+
// ─────────────────────────────────────────────────────────────────
|
|
276
|
+
// Patch mode execution
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────
|
|
278
|
+
if (this.patchMode) {
|
|
279
|
+
const { path, op: rawOp, rename, diff } = params as PatchParams;
|
|
280
|
+
|
|
281
|
+
// Normalize unrecognized operations to "update"
|
|
282
|
+
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
|
|
283
|
+
|
|
284
|
+
if (path.endsWith(".ipynb")) {
|
|
285
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
286
|
+
}
|
|
287
|
+
if (rename?.endsWith(".ipynb")) {
|
|
288
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const input: PatchInput = { path, op, rename, diff };
|
|
292
|
+
const fs = new LspFileSystem(this.writethrough, signal, batchRequest);
|
|
293
|
+
const result = await applyPatch(input, {
|
|
294
|
+
cwd: this.session.cwd,
|
|
295
|
+
fs,
|
|
296
|
+
fuzzyThreshold: this.fuzzyThreshold,
|
|
297
|
+
allowFuzzy: this.allowFuzzy,
|
|
298
|
+
});
|
|
299
|
+
const effRename = result.change.newPath ? rename : undefined;
|
|
300
|
+
|
|
301
|
+
// Generate diff for display
|
|
302
|
+
let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
|
|
303
|
+
let normative: PatchInput | undefined;
|
|
304
|
+
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
|
|
305
|
+
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
|
|
306
|
+
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
|
|
307
|
+
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
|
|
308
|
+
normative = buildNormativeUpdateInput({
|
|
309
|
+
path,
|
|
310
|
+
rename: effRename,
|
|
311
|
+
oldContent: result.change.oldContent,
|
|
312
|
+
newContent: result.change.newContent,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let resultText: string;
|
|
317
|
+
switch (result.change.type) {
|
|
318
|
+
case "create":
|
|
319
|
+
resultText = `Created ${path}`;
|
|
320
|
+
break;
|
|
321
|
+
case "delete":
|
|
322
|
+
resultText = `Deleted ${path}`;
|
|
323
|
+
break;
|
|
324
|
+
case "update":
|
|
325
|
+
resultText = effRename ? `Updated and moved ${path} to ${effRename}` : `Updated ${path}`;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let diagnostics = fs.getDiagnostics();
|
|
330
|
+
if (op === "delete" && batchRequest?.flush) {
|
|
331
|
+
const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal);
|
|
332
|
+
diagnostics ??= flushedDiagnostics;
|
|
333
|
+
}
|
|
334
|
+
if (diagnostics?.messages?.length) {
|
|
335
|
+
resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
|
|
336
|
+
resultText += diagnostics.messages.map((d) => ` ${d}`).join("\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
content: [{ type: "text", text: resultText }],
|
|
341
|
+
details: {
|
|
342
|
+
diff: diffResult.diff,
|
|
343
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
344
|
+
diagnostics,
|
|
345
|
+
op,
|
|
346
|
+
rename: effRename,
|
|
347
|
+
},
|
|
348
|
+
$normative: normative,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─────────────────────────────────────────────────────────────────
|
|
353
|
+
// Replace mode execution
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────
|
|
355
|
+
const { path, oldText, newText, all } = params as ReplaceParams;
|
|
356
|
+
|
|
357
|
+
if (path.endsWith(".ipynb")) {
|
|
358
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (oldText.length === 0) {
|
|
362
|
+
throw new Error("oldText must not be empty.");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const absolutePath = resolveToCwd(path, this.session.cwd);
|
|
366
|
+
const file = Bun.file(absolutePath);
|
|
367
|
+
|
|
368
|
+
if (!(await file.exists())) {
|
|
369
|
+
throw new Error(`File not found: ${path}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const rawContent = await file.text();
|
|
373
|
+
const { bom, text: content } = stripBom(rawContent);
|
|
374
|
+
const originalEnding = detectLineEnding(content);
|
|
375
|
+
const normalizedContent = normalizeToLF(content);
|
|
376
|
+
const normalizedOldText = normalizeToLF(oldText);
|
|
377
|
+
const normalizedNewText = normalizeToLF(newText);
|
|
378
|
+
|
|
379
|
+
const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
|
|
380
|
+
fuzzy: this.allowFuzzy,
|
|
381
|
+
all: all ?? false,
|
|
382
|
+
threshold: this.fuzzyThreshold,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (result.count === 0) {
|
|
386
|
+
// Get error details
|
|
387
|
+
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
388
|
+
allowFuzzy: this.allowFuzzy,
|
|
389
|
+
threshold: this.fuzzyThreshold,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
|
|
399
|
+
allowFuzzy: this.allowFuzzy,
|
|
400
|
+
threshold: this.fuzzyThreshold,
|
|
401
|
+
fuzzyMatches: matchOutcome.fuzzyMatches,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (normalizedContent === result.content) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`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.`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const finalContent = bom + restoreLineEndings(result.content, originalEnding);
|
|
412
|
+
const diagnostics = await this.writethrough(absolutePath, finalContent, signal, file, batchRequest);
|
|
413
|
+
const diffResult = generateDiffString(normalizedContent, result.content);
|
|
414
|
+
|
|
415
|
+
let resultText =
|
|
416
|
+
result.count > 1
|
|
417
|
+
? `Successfully replaced ${result.count} occurrences in ${path}.`
|
|
418
|
+
: `Successfully replaced text in ${path}.`;
|
|
419
|
+
|
|
420
|
+
if (diagnostics?.messages?.length) {
|
|
421
|
+
resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
|
|
422
|
+
resultText += diagnostics.messages.map((d) => ` ${d}`).join("\n");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
content: [{ type: "text", text: resultText }],
|
|
427
|
+
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics },
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text normalization utilities for the edit tool.
|
|
3
|
+
*
|
|
4
|
+
* Handles line endings, BOM, whitespace, and Unicode normalization.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
// Line Ending Utilities
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
export type LineEnding = "\r\n" | "\n";
|
|
12
|
+
|
|
13
|
+
/** Detect the predominant line ending in content */
|
|
14
|
+
export function detectLineEnding(content: string): LineEnding {
|
|
15
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
16
|
+
const lfIdx = content.indexOf("\n");
|
|
17
|
+
if (lfIdx === -1) return "\n";
|
|
18
|
+
if (crlfIdx === -1) return "\n";
|
|
19
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalize all line endings to LF */
|
|
23
|
+
export function normalizeToLF(text: string): string {
|
|
24
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Restore line endings to the specified type */
|
|
28
|
+
export function restoreLineEndings(text: string, ending: LineEnding): string {
|
|
29
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
33
|
+
// BOM Handling
|
|
34
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
export interface BomResult {
|
|
37
|
+
/** The BOM character if present, empty string otherwise */
|
|
38
|
+
bom: string;
|
|
39
|
+
/** The text without the BOM */
|
|
40
|
+
text: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Strip UTF-8 BOM if present */
|
|
44
|
+
export function stripBom(content: string): BomResult {
|
|
45
|
+
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
// Whitespace Utilities
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
/** Count leading whitespace characters in a line */
|
|
53
|
+
export function countLeadingWhitespace(line: string): number {
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (let i = 0; i < line.length; i++) {
|
|
56
|
+
const char = line[i];
|
|
57
|
+
if (char === " " || char === "\t") {
|
|
58
|
+
count++;
|
|
59
|
+
} else {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Get the leading whitespace string from a line */
|
|
67
|
+
export function getLeadingWhitespace(line: string): string {
|
|
68
|
+
return line.slice(0, countLeadingWhitespace(line));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compute minimum indentation of non-empty lines */
|
|
72
|
+
export function minIndent(text: string): number {
|
|
73
|
+
const lines = text.split("\n");
|
|
74
|
+
let min = Infinity;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
if (line.trim().length > 0) {
|
|
77
|
+
min = Math.min(min, countLeadingWhitespace(line));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return min === Infinity ? 0 : min;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Detect the indentation character used in text (space or tab) */
|
|
84
|
+
export function detectIndentChar(text: string): string {
|
|
85
|
+
const lines = text.split("\n");
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const ws = getLeadingWhitespace(line);
|
|
88
|
+
if (ws.length > 0) {
|
|
89
|
+
return ws[0];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return " ";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
|
+
// Unicode Normalization
|
|
97
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize common Unicode punctuation to ASCII equivalents.
|
|
101
|
+
* Allows diffs with ASCII characters to match source files with typographic punctuation.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeUnicode(s: string): string {
|
|
104
|
+
return s
|
|
105
|
+
.trim()
|
|
106
|
+
.split("")
|
|
107
|
+
.map((c) => {
|
|
108
|
+
const code = c.charCodeAt(0);
|
|
109
|
+
|
|
110
|
+
// Various dash/hyphen code-points → ASCII '-'
|
|
111
|
+
if (
|
|
112
|
+
code === 0x2010 || // HYPHEN
|
|
113
|
+
code === 0x2011 || // NON-BREAKING HYPHEN
|
|
114
|
+
code === 0x2012 || // FIGURE DASH
|
|
115
|
+
code === 0x2013 || // EN DASH
|
|
116
|
+
code === 0x2014 || // EM DASH
|
|
117
|
+
code === 0x2015 || // HORIZONTAL BAR
|
|
118
|
+
code === 0x2212 // MINUS SIGN
|
|
119
|
+
) {
|
|
120
|
+
return "-";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fancy single quotes → '
|
|
124
|
+
if (
|
|
125
|
+
code === 0x2018 || // LEFT SINGLE QUOTATION MARK
|
|
126
|
+
code === 0x2019 || // RIGHT SINGLE QUOTATION MARK
|
|
127
|
+
code === 0x201a || // SINGLE LOW-9 QUOTATION MARK
|
|
128
|
+
code === 0x201b // SINGLE HIGH-REVERSED-9 QUOTATION MARK
|
|
129
|
+
) {
|
|
130
|
+
return "'";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fancy double quotes → "
|
|
134
|
+
if (
|
|
135
|
+
code === 0x201c || // LEFT DOUBLE QUOTATION MARK
|
|
136
|
+
code === 0x201d || // RIGHT DOUBLE QUOTATION MARK
|
|
137
|
+
code === 0x201e || // DOUBLE LOW-9 QUOTATION MARK
|
|
138
|
+
code === 0x201f // DOUBLE HIGH-REVERSED-9 QUOTATION MARK
|
|
139
|
+
) {
|
|
140
|
+
return '"';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Non-breaking space and other odd spaces → normal space
|
|
144
|
+
if (
|
|
145
|
+
code === 0x00a0 || // NO-BREAK SPACE
|
|
146
|
+
code === 0x2002 || // EN SPACE
|
|
147
|
+
code === 0x2003 || // EM SPACE
|
|
148
|
+
code === 0x2004 || // THREE-PER-EM SPACE
|
|
149
|
+
code === 0x2005 || // FOUR-PER-EM SPACE
|
|
150
|
+
code === 0x2006 || // SIX-PER-EM SPACE
|
|
151
|
+
code === 0x2007 || // FIGURE SPACE
|
|
152
|
+
code === 0x2008 || // PUNCTUATION SPACE
|
|
153
|
+
code === 0x2009 || // THIN SPACE
|
|
154
|
+
code === 0x200a || // HAIR SPACE
|
|
155
|
+
code === 0x202f || // NARROW NO-BREAK SPACE
|
|
156
|
+
code === 0x205f || // MEDIUM MATHEMATICAL SPACE
|
|
157
|
+
code === 0x3000 // IDEOGRAPHIC SPACE
|
|
158
|
+
) {
|
|
159
|
+
return " ";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return c;
|
|
163
|
+
})
|
|
164
|
+
.join("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Normalize a line for fuzzy comparison.
|
|
169
|
+
* Trims, collapses whitespace, and normalizes punctuation.
|
|
170
|
+
*/
|
|
171
|
+
export function normalizeForFuzzy(line: string): string {
|
|
172
|
+
const trimmed = line.trim();
|
|
173
|
+
if (trimmed.length === 0) return "";
|
|
174
|
+
|
|
175
|
+
return trimmed
|
|
176
|
+
.replace(/[""„‟«»]/g, '"')
|
|
177
|
+
.replace(/[''‚‛`´]/g, "'")
|
|
178
|
+
.replace(/[‐‑‒–—−]/g, "-")
|
|
179
|
+
.replace(/[ \t]+/g, " ");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
183
|
+
// Indentation Adjustment
|
|
184
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Adjust newText indentation to match the indentation delta between
|
|
188
|
+
* what was provided (oldText) and what was actually matched (actualText).
|
|
189
|
+
*
|
|
190
|
+
* If oldText has 0 indent but actualText has 12 spaces, we add 12 spaces
|
|
191
|
+
* to each line in newText.
|
|
192
|
+
*/
|
|
193
|
+
export function adjustIndentation(oldText: string, actualText: string, newText: string): string {
|
|
194
|
+
const oldMin = minIndent(oldText);
|
|
195
|
+
const actualMin = minIndent(actualText);
|
|
196
|
+
const delta = actualMin - oldMin;
|
|
197
|
+
|
|
198
|
+
if (delta === 0) {
|
|
199
|
+
return newText;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const indentChar = detectIndentChar(actualText);
|
|
203
|
+
const lines = newText.split("\n");
|
|
204
|
+
|
|
205
|
+
const adjusted = lines.map((line) => {
|
|
206
|
+
if (line.trim().length === 0) {
|
|
207
|
+
return line; // Preserve empty/whitespace-only lines as-is
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (delta > 0) {
|
|
211
|
+
return indentChar.repeat(delta) + line;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Remove indentation (delta < 0)
|
|
215
|
+
const toRemove = Math.min(-delta, countLeadingWhitespace(line));
|
|
216
|
+
return line.slice(toRemove);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return adjusted.join("\n");
|
|
220
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize applied patch output into a canonical edit tool payload.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateUnifiedDiffString } from "./diff";
|
|
6
|
+
import { normalizeToLF, stripBom } from "./normalize";
|
|
7
|
+
import type { PatchInput } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface NormativePatchOptions {
|
|
10
|
+
path: string;
|
|
11
|
+
rename?: string;
|
|
12
|
+
oldContent: string;
|
|
13
|
+
newContent: string;
|
|
14
|
+
contextLines?: number;
|
|
15
|
+
anchor?: string | string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Normative patch input is the MongoDB-style update variant */
|
|
19
|
+
|
|
20
|
+
function applyAnchors(diff: string, anchors: string[] | undefined): string {
|
|
21
|
+
if (!anchors || anchors.length === 0) {
|
|
22
|
+
return diff;
|
|
23
|
+
}
|
|
24
|
+
const lines = diff.split("\n");
|
|
25
|
+
let anchorIndex = 0;
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
if (!lines[i].startsWith("@@")) continue;
|
|
28
|
+
const anchor = anchors[anchorIndex];
|
|
29
|
+
if (anchor !== undefined) {
|
|
30
|
+
lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
|
|
31
|
+
anchorIndex++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
|
|
38
|
+
const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
|
|
39
|
+
const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
|
|
40
|
+
const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
|
|
41
|
+
const anchors = typeof options.anchor === "string" ? [options.anchor] : options.anchor;
|
|
42
|
+
const diff = applyAnchors(diffResult.diff, anchors);
|
|
43
|
+
return {
|
|
44
|
+
path: options.path,
|
|
45
|
+
op: "update",
|
|
46
|
+
rename: options.rename,
|
|
47
|
+
diff,
|
|
48
|
+
};
|
|
49
|
+
}
|