@oh-my-pi/pi-coding-agent 3.5.1337 → 3.8.1337

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.
@@ -249,40 +249,52 @@ function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLi
249
249
  return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" };
250
250
  }
251
251
 
252
- export function formatEditMatchError(
253
- path: string,
254
- normalizedOldText: string,
255
- closest: EditMatch | undefined,
256
- options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
257
- ): string {
258
- if (!closest) {
259
- return options.allowFuzzy
260
- ? `Could not find a close enough match in ${path}.`
261
- : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`;
252
+ export class EditMatchError extends Error {
253
+ constructor(
254
+ public readonly path: string,
255
+ public readonly normalizedOldText: string,
256
+ public readonly closest: EditMatch | undefined,
257
+ public readonly options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
258
+ ) {
259
+ super(EditMatchError.formatMessage(path, normalizedOldText, closest, options));
260
+ this.name = "EditMatchError";
262
261
  }
263
262
 
264
- const similarity = Math.round(closest.confidence * 100);
265
- const oldLines = normalizedOldText.split("\n");
266
- const actualLines = closest.actualText.split("\n");
267
- const { oldLine, newLine } = findFirstDifferentLine(oldLines, actualLines);
268
- const thresholdPercent = Math.round(options.similarityThreshold * 100);
269
-
270
- const hint = options.allowFuzzy
271
- ? options.fuzzyMatches && options.fuzzyMatches > 1
272
- ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
273
- : `Closest match was below the ${thresholdPercent}% similarity threshold.`
274
- : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
275
-
276
- return [
277
- options.allowFuzzy
278
- ? `Could not find a close enough match in ${path}.`
279
- : `Could not find the exact text in ${path}.`,
280
- ``,
281
- `Closest match (${similarity}% similar) at line ${closest.startLine}:`,
282
- ` - ${oldLine}`,
283
- ` + ${newLine}`,
284
- hint,
285
- ].join("\n");
263
+ static formatMessage(
264
+ path: string,
265
+ normalizedOldText: string,
266
+ closest: EditMatch | undefined,
267
+ options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
268
+ ): string {
269
+ if (!closest) {
270
+ return options.allowFuzzy
271
+ ? `Could not find a close enough match in ${path}.`
272
+ : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`;
273
+ }
274
+
275
+ const similarity = Math.round(closest.confidence * 100);
276
+ const oldLines = normalizedOldText.split("\n");
277
+ const actualLines = closest.actualText.split("\n");
278
+ const { oldLine, newLine } = findFirstDifferentLine(oldLines, actualLines);
279
+ const thresholdPercent = Math.round(options.similarityThreshold * 100);
280
+
281
+ const hint = options.allowFuzzy
282
+ ? options.fuzzyMatches && options.fuzzyMatches > 1
283
+ ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
284
+ : `Closest match was below the ${thresholdPercent}% similarity threshold.`
285
+ : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
286
+
287
+ return [
288
+ options.allowFuzzy
289
+ ? `Could not find a close enough match in ${path}.`
290
+ : `Could not find the exact text in ${path}.`,
291
+ ``,
292
+ `Closest match (${similarity}% similar) at line ${closest.startLine}:`,
293
+ ` - ${oldLine}`,
294
+ ` + ${newLine}`,
295
+ hint,
296
+ ].join("\n");
297
+ }
286
298
  }
287
299
 
288
300
  /**
@@ -444,7 +456,7 @@ export async function computeEditDiff(
444
456
 
445
457
  if (!matchOutcome.match) {
446
458
  return {
447
- error: formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
459
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
448
460
  allowFuzzy: fuzzy,
449
461
  similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
450
462
  fuzzyMatches: matchOutcome.fuzzyMatches,
@@ -1,18 +1,16 @@
1
- import { constants } from "node:fs";
2
- import { access, readFile, writeFile } from "node:fs/promises";
3
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
2
  import { Type } from "@sinclair/typebox";
5
3
  import {
6
4
  DEFAULT_FUZZY_THRESHOLD,
7
5
  detectLineEnding,
6
+ EditMatchError,
8
7
  findEditMatch,
9
- formatEditMatchError,
10
8
  generateDiffString,
11
9
  normalizeToLF,
12
10
  restoreLineEndings,
13
11
  stripBom,
14
12
  } from "./edit-diff";
15
- import type { FileDiagnosticsResult } from "./lsp/index";
13
+ import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
16
14
  import { resolveToCwd } from "./path-utils";
17
15
 
18
16
  const editSchema = Type.Object({
@@ -28,8 +26,6 @@ export interface EditToolDetails {
28
26
  diff: string;
29
27
  /** Line number of the first change in the new file (for editor navigation) */
30
28
  firstChangedLine?: number;
31
- /** Whether LSP diagnostics were retrieved */
32
- hasDiagnostics?: boolean;
33
29
  /** Diagnostic result (if available) */
34
30
  diagnostics?: FileDiagnosticsResult;
35
31
  }
@@ -37,12 +33,13 @@ export interface EditToolDetails {
37
33
  export interface EditToolOptions {
38
34
  /** Whether to accept high-confidence fuzzy matches for whitespace/indentation (default: true) */
39
35
  fuzzyMatch?: boolean;
40
- /** Callback to get LSP diagnostics after editing a file */
41
- getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
36
+ /** Writethrough callback to get LSP diagnostics after editing a file */
37
+ writethrough?: WritethroughCallback;
42
38
  }
43
39
 
44
40
  export function createEditTool(cwd: string, options: EditToolOptions = {}): AgentTool<typeof editSchema> {
45
41
  const allowFuzzy = options.fuzzyMatch ?? true;
42
+ const writethrough = options.writethrough ?? writethroughNoop;
46
43
  return {
47
44
  name: "edit",
48
45
  label: "Edit",
@@ -61,196 +58,87 @@ Usage:
61
58
  { path, oldText, newText }: { path: string; oldText: string; newText: string },
62
59
  signal?: AbortSignal,
63
60
  ) => {
64
- const absolutePath = resolveToCwd(path, cwd);
65
-
66
61
  // Reject .ipynb files - use NotebookEdit tool instead
67
- if (absolutePath.endsWith(".ipynb")) {
68
- return {
69
- content: [
70
- {
71
- type: "text",
72
- text: "Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.",
73
- },
74
- ],
75
- details: undefined,
76
- };
62
+ if (path.endsWith(".ipynb")) {
63
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
77
64
  }
78
65
 
79
- return new Promise<{
80
- content: Array<{ type: "text"; text: string }>;
81
- details: EditToolDetails | undefined;
82
- }>((resolve, reject) => {
83
- // Check if already aborted
84
- if (signal?.aborted) {
85
- reject(new Error("Operation aborted"));
86
- return;
87
- }
88
-
89
- let aborted = false;
90
-
91
- // Set up abort handler
92
- const onAbort = () => {
93
- aborted = true;
94
- reject(new Error("Operation aborted"));
95
- };
96
-
97
- if (signal) {
98
- signal.addEventListener("abort", onAbort, { once: true });
99
- }
100
-
101
- // Perform the edit operation
102
- (async () => {
103
- try {
104
- // Check if file exists
105
- try {
106
- await access(absolutePath, constants.R_OK | constants.W_OK);
107
- } catch {
108
- if (signal) {
109
- signal.removeEventListener("abort", onAbort);
110
- }
111
- reject(new Error(`File not found: ${path}`));
112
- return;
113
- }
114
-
115
- // Check if aborted before reading
116
- if (aborted) {
117
- return;
118
- }
119
-
120
- // Read the file
121
- const rawContent = await readFile(absolutePath, "utf-8");
122
-
123
- // Check if aborted after reading
124
- if (aborted) {
125
- return;
126
- }
127
-
128
- // Strip BOM before matching (LLM won't include invisible BOM in oldText)
129
- const { bom, text: content } = stripBom(rawContent);
130
-
131
- const originalEnding = detectLineEnding(content);
132
- const normalizedContent = normalizeToLF(content);
133
- const normalizedOldText = normalizeToLF(oldText);
134
- const normalizedNewText = normalizeToLF(newText);
135
-
136
- const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
137
- allowFuzzy,
138
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
139
- });
140
-
141
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
142
- if (signal) {
143
- signal.removeEventListener("abort", onAbort);
144
- }
145
- reject(
146
- new Error(
147
- `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
148
- ),
149
- );
150
- return;
151
- }
152
-
153
- if (!matchOutcome.match) {
154
- if (signal) {
155
- signal.removeEventListener("abort", onAbort);
156
- }
157
- reject(
158
- new Error(
159
- formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
160
- allowFuzzy,
161
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
162
- fuzzyMatches: matchOutcome.fuzzyMatches,
163
- }),
164
- ),
165
- );
166
- return;
167
- }
66
+ const absolutePath = resolveToCwd(path, cwd);
168
67
 
169
- const match = matchOutcome.match;
68
+ const file = Bun.file(absolutePath);
69
+ if (!(await file.exists())) {
70
+ throw new Error(`File not found: ${path}`);
71
+ }
170
72
 
171
- // Check if aborted before writing
172
- if (aborted) {
173
- return;
174
- }
73
+ const rawContent = await file.text();
175
74
 
176
- const normalizedNewContent =
177
- normalizedContent.substring(0, match.startIndex) +
178
- normalizedNewText +
179
- normalizedContent.substring(match.startIndex + match.actualText.length);
75
+ // Strip BOM before matching (LLM won't include invisible BOM in oldText)
76
+ const { bom, text: content } = stripBom(rawContent);
180
77
 
181
- // Verify the replacement actually changed something
182
- if (normalizedContent === normalizedNewContent) {
183
- if (signal) {
184
- signal.removeEventListener("abort", onAbort);
185
- }
186
- reject(
187
- new Error(
188
- `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.`,
189
- ),
190
- );
191
- return;
192
- }
78
+ const originalEnding = detectLineEnding(content);
79
+ const normalizedContent = normalizeToLF(content);
80
+ const normalizedOldText = normalizeToLF(oldText);
81
+ const normalizedNewText = normalizeToLF(newText);
193
82
 
194
- const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
195
- await writeFile(absolutePath, finalContent, "utf-8");
83
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
84
+ allowFuzzy,
85
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
86
+ });
196
87
 
197
- // Check if aborted after writing
198
- if (aborted) {
199
- return;
200
- }
88
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
89
+ throw new Error(
90
+ `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
91
+ );
92
+ }
201
93
 
202
- // Clean up abort handler
203
- if (signal) {
204
- signal.removeEventListener("abort", onAbort);
205
- }
94
+ if (!matchOutcome.match) {
95
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
96
+ allowFuzzy,
97
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
98
+ fuzzyMatches: matchOutcome.fuzzyMatches,
99
+ });
100
+ }
206
101
 
207
- const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
102
+ const match = matchOutcome.match;
103
+ const normalizedNewContent =
104
+ normalizedContent.substring(0, match.startIndex) +
105
+ normalizedNewText +
106
+ normalizedContent.substring(match.startIndex + match.actualText.length);
107
+
108
+ // Verify the replacement actually changed something
109
+ if (normalizedContent === normalizedNewContent) {
110
+ throw new Error(
111
+ `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.`,
112
+ );
113
+ }
208
114
 
209
- // Get LSP diagnostics if callback provided
210
- let diagnosticsResult: FileDiagnosticsResult | undefined;
211
- if (options.getDiagnostics) {
212
- try {
213
- diagnosticsResult = await options.getDiagnostics(absolutePath);
214
- } catch {
215
- // Ignore diagnostics errors - don't fail the edit
216
- }
217
- }
115
+ const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
116
+ const diagnostics = await writethrough(absolutePath, finalContent, signal, file);
218
117
 
219
- // Build result text
220
- let resultText = `Successfully replaced text in ${path}.`;
118
+ const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
221
119
 
222
- // Append diagnostics if available and there are issues
223
- if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
224
- resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
225
- resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
226
- }
120
+ // Build result text
121
+ let resultText = `Successfully replaced text in ${path}.`;
227
122
 
228
- resolve({
229
- content: [
230
- {
231
- type: "text",
232
- text: resultText,
233
- },
234
- ],
235
- details: {
236
- diff: diffResult.diff,
237
- firstChangedLine: diffResult.firstChangedLine,
238
- hasDiagnostics: diagnosticsResult?.available ?? false,
239
- diagnostics: diagnosticsResult,
240
- },
241
- });
242
- } catch (error: any) {
243
- // Clean up abort handler
244
- if (signal) {
245
- signal.removeEventListener("abort", onAbort);
246
- }
123
+ const messages = diagnostics?.messages;
124
+ if (messages && messages.length > 0) {
125
+ resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
126
+ resultText += messages.map((d) => ` ${d}`).join("\n");
127
+ }
247
128
 
248
- if (!aborted) {
249
- reject(error);
250
- }
251
- }
252
- })();
253
- });
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: resultText,
134
+ },
135
+ ],
136
+ details: {
137
+ diff: diffResult.diff,
138
+ firstChangedLine: diffResult.firstChangedLine,
139
+ diagnostics: diagnostics,
140
+ },
141
+ };
254
142
  },
255
143
  };
256
144
  }