@ottocode/sdk 0.1.180 → 0.1.182
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/package.json +1 -1
- package/src/core/src/index.ts +2 -0
- package/src/core/src/providers/resolver.ts +1 -1
- package/src/core/src/tools/builtin/edit/replacers.ts +461 -0
- package/src/core/src/tools/builtin/edit.ts +166 -0
- package/src/core/src/tools/builtin/edit.txt +10 -0
- package/src/core/src/tools/builtin/fs/read.ts +58 -11
- package/src/core/src/tools/builtin/multiedit.ts +190 -0
- package/src/core/src/tools/builtin/multiedit.txt +15 -0
- package/src/core/src/tools/builtin/patch/apply.ts +280 -30
- package/src/core/src/tools/builtin/patch/constants.ts +3 -0
- package/src/core/src/tools/builtin/patch/normalize.ts +173 -3
- package/src/core/src/tools/builtin/patch/parse-enveloped.ts +99 -3
- package/src/core/src/tools/builtin/patch/repair.ts +47 -0
- package/src/core/src/tools/builtin/patch.ts +3 -0
- package/src/core/src/tools/loader.ts +7 -0
- package/src/index.ts +2 -0
- package/src/prompts/src/agents/build.txt +191 -55
- package/src/prompts/src/base.txt +5 -5
- package/src/prompts/src/providers/anthropic.txt +22 -3
- package/src/prompts/src/providers/default.txt +32 -20
- package/src/prompts/src/providers/glm.txt +352 -0
- package/src/prompts/src/providers/google.txt +34 -0
- package/src/prompts/src/providers/moonshot.txt +149 -19
- package/src/prompts/src/providers/openai.txt +22 -12
- package/src/prompts/src/providers.ts +10 -1
- package/src/providers/src/env.ts +1 -1
- package/src/providers/src/openai-oauth-client.ts +85 -30
- package/src/providers/src/utils.ts +11 -0
- package/src/providers/src/zai-client.ts +1 -1
package/package.json
CHANGED
package/src/core/src/index.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type {
|
|
|
50
50
|
export { buildFsTools } from './tools/builtin/fs/index';
|
|
51
51
|
export { buildGitTools } from './tools/builtin/git';
|
|
52
52
|
export { buildTerminalTool } from './tools/builtin/terminal';
|
|
53
|
+
export { buildEditTool } from './tools/builtin/edit';
|
|
54
|
+
export { buildMultiEditTool } from './tools/builtin/multiedit';
|
|
53
55
|
|
|
54
56
|
// =======================
|
|
55
57
|
// Terminals
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
export type Replacer = (
|
|
2
|
+
content: string,
|
|
3
|
+
find: string,
|
|
4
|
+
) => Generator<string, void, unknown>;
|
|
5
|
+
|
|
6
|
+
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
|
|
7
|
+
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;
|
|
8
|
+
|
|
9
|
+
function levenshtein(a: string, b: string): number {
|
|
10
|
+
if (a === '' || b === '') {
|
|
11
|
+
return Math.max(a.length, b.length);
|
|
12
|
+
}
|
|
13
|
+
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
14
|
+
Array.from({ length: b.length + 1 }, (_, j) =>
|
|
15
|
+
i === 0 ? j : j === 0 ? i : 0,
|
|
16
|
+
),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
for (let i = 1; i <= a.length; i++) {
|
|
20
|
+
for (let j = 1; j <= b.length; j++) {
|
|
21
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
22
|
+
matrix[i][j] = Math.min(
|
|
23
|
+
matrix[i - 1][j] + 1,
|
|
24
|
+
matrix[i][j - 1] + 1,
|
|
25
|
+
matrix[i - 1][j - 1] + cost,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return matrix[a.length][b.length];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const SimpleReplacer: Replacer = function* (_content, find) {
|
|
33
|
+
yield find;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
37
|
+
const originalLines = content.split('\n');
|
|
38
|
+
const searchLines = find.split('\n');
|
|
39
|
+
|
|
40
|
+
if (searchLines[searchLines.length - 1] === '') {
|
|
41
|
+
searchLines.pop();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
45
|
+
let matches = true;
|
|
46
|
+
|
|
47
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
48
|
+
const originalTrimmed = originalLines[i + j].trim();
|
|
49
|
+
const searchTrimmed = searchLines[j].trim();
|
|
50
|
+
|
|
51
|
+
if (originalTrimmed !== searchTrimmed) {
|
|
52
|
+
matches = false;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (matches) {
|
|
58
|
+
let matchStartIndex = 0;
|
|
59
|
+
for (let k = 0; k < i; k++) {
|
|
60
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let matchEndIndex = matchStartIndex;
|
|
64
|
+
for (let k = 0; k < searchLines.length; k++) {
|
|
65
|
+
matchEndIndex += originalLines[i + k].length;
|
|
66
|
+
if (k < searchLines.length - 1) {
|
|
67
|
+
matchEndIndex += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
77
|
+
const originalLines = content.split('\n');
|
|
78
|
+
const searchLines = find.split('\n');
|
|
79
|
+
|
|
80
|
+
if (searchLines.length < 3) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (searchLines[searchLines.length - 1] === '') {
|
|
85
|
+
searchLines.pop();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const firstLineSearch = searchLines[0].trim();
|
|
89
|
+
const lastLineSearch = searchLines[searchLines.length - 1].trim();
|
|
90
|
+
|
|
91
|
+
const candidates: Array<{ startLine: number; endLine: number }> = [];
|
|
92
|
+
for (let i = 0; i < originalLines.length; i++) {
|
|
93
|
+
if (originalLines[i].trim() !== firstLineSearch) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (let j = i + 2; j < originalLines.length; j++) {
|
|
98
|
+
if (originalLines[j].trim() === lastLineSearch) {
|
|
99
|
+
candidates.push({ startLine: i, endLine: j });
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (candidates.length === 0) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (candidates.length === 1) {
|
|
110
|
+
const { startLine, endLine } = candidates[0];
|
|
111
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
112
|
+
const searchBlockSize = searchLines.length;
|
|
113
|
+
|
|
114
|
+
let similarity = 0;
|
|
115
|
+
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
116
|
+
|
|
117
|
+
if (linesToCheck > 0) {
|
|
118
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
119
|
+
const originalLine = originalLines[startLine + j].trim();
|
|
120
|
+
const searchLine = searchLines[j].trim();
|
|
121
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
122
|
+
if (maxLen === 0) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
126
|
+
similarity += (1 - distance / maxLen) / linesToCheck;
|
|
127
|
+
|
|
128
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
similarity = 1.0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
137
|
+
let matchStartIndex = 0;
|
|
138
|
+
for (let k = 0; k < startLine; k++) {
|
|
139
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
140
|
+
}
|
|
141
|
+
let matchEndIndex = matchStartIndex;
|
|
142
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
143
|
+
matchEndIndex += originalLines[k].length;
|
|
144
|
+
if (k < endLine) {
|
|
145
|
+
matchEndIndex += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let bestMatch: { startLine: number; endLine: number } | null = null;
|
|
154
|
+
let maxSimilarity = -1;
|
|
155
|
+
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const { startLine, endLine } = candidate;
|
|
158
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
159
|
+
const searchBlockSize = searchLines.length;
|
|
160
|
+
|
|
161
|
+
let similarity = 0;
|
|
162
|
+
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
163
|
+
|
|
164
|
+
if (linesToCheck > 0) {
|
|
165
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
166
|
+
const originalLine = originalLines[startLine + j].trim();
|
|
167
|
+
const searchLine = searchLines[j].trim();
|
|
168
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
169
|
+
if (maxLen === 0) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
173
|
+
similarity += 1 - distance / maxLen;
|
|
174
|
+
}
|
|
175
|
+
similarity /= linesToCheck;
|
|
176
|
+
} else {
|
|
177
|
+
similarity = 1.0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (similarity > maxSimilarity) {
|
|
181
|
+
maxSimilarity = similarity;
|
|
182
|
+
bestMatch = candidate;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
|
|
187
|
+
const { startLine, endLine } = bestMatch;
|
|
188
|
+
let matchStartIndex = 0;
|
|
189
|
+
for (let k = 0; k < startLine; k++) {
|
|
190
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
191
|
+
}
|
|
192
|
+
let matchEndIndex = matchStartIndex;
|
|
193
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
194
|
+
matchEndIndex += originalLines[k].length;
|
|
195
|
+
if (k < endLine) {
|
|
196
|
+
matchEndIndex += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const WhitespaceNormalizedReplacer: Replacer = function* (
|
|
204
|
+
content,
|
|
205
|
+
find,
|
|
206
|
+
) {
|
|
207
|
+
const normalizeWhitespace = (text: string) =>
|
|
208
|
+
text.replace(/\s+/g, ' ').trim();
|
|
209
|
+
const normalizedFind = normalizeWhitespace(find);
|
|
210
|
+
|
|
211
|
+
const lines = content.split('\n');
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const line = lines[i];
|
|
214
|
+
if (normalizeWhitespace(line) === normalizedFind) {
|
|
215
|
+
yield line;
|
|
216
|
+
} else {
|
|
217
|
+
const normalizedLine = normalizeWhitespace(line);
|
|
218
|
+
if (normalizedLine.includes(normalizedFind)) {
|
|
219
|
+
const words = find.trim().split(/\s+/);
|
|
220
|
+
if (words.length > 0) {
|
|
221
|
+
const pattern = words
|
|
222
|
+
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
223
|
+
.join('\\s+');
|
|
224
|
+
try {
|
|
225
|
+
const regex = new RegExp(pattern);
|
|
226
|
+
const match = line.match(regex);
|
|
227
|
+
if (match) {
|
|
228
|
+
yield match[0];
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// skip invalid regex
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const findLines = find.split('\n');
|
|
239
|
+
if (findLines.length > 1) {
|
|
240
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
241
|
+
const block = lines.slice(i, i + findLines.length);
|
|
242
|
+
if (normalizeWhitespace(block.join('\n')) === normalizedFind) {
|
|
243
|
+
yield block.join('\n');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
250
|
+
const removeIndentation = (text: string) => {
|
|
251
|
+
const lines = text.split('\n');
|
|
252
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
|
|
253
|
+
if (nonEmptyLines.length === 0) return text;
|
|
254
|
+
|
|
255
|
+
const minIndent = Math.min(
|
|
256
|
+
...nonEmptyLines.map((line) => {
|
|
257
|
+
const match = line.match(/^(\s*)/);
|
|
258
|
+
return match ? match[1].length : 0;
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
return lines
|
|
263
|
+
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
|
264
|
+
.join('\n');
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const normalizedFind = removeIndentation(find);
|
|
268
|
+
const contentLines = content.split('\n');
|
|
269
|
+
const findLines = find.split('\n');
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
272
|
+
const block = contentLines.slice(i, i + findLines.length).join('\n');
|
|
273
|
+
if (removeIndentation(block) === normalizedFind) {
|
|
274
|
+
yield block;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
280
|
+
const unescapeString = (str: string): string => {
|
|
281
|
+
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
|
282
|
+
switch (capturedChar) {
|
|
283
|
+
case 'n':
|
|
284
|
+
return '\n';
|
|
285
|
+
case 't':
|
|
286
|
+
return '\t';
|
|
287
|
+
case 'r':
|
|
288
|
+
return '\r';
|
|
289
|
+
case "'":
|
|
290
|
+
return "'";
|
|
291
|
+
case '"':
|
|
292
|
+
return '"';
|
|
293
|
+
case '`':
|
|
294
|
+
return '`';
|
|
295
|
+
case '\\':
|
|
296
|
+
return '\\';
|
|
297
|
+
case '\n':
|
|
298
|
+
return '\n';
|
|
299
|
+
case '$':
|
|
300
|
+
return '$';
|
|
301
|
+
default:
|
|
302
|
+
return match;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const unescapedFind = unescapeString(find);
|
|
308
|
+
|
|
309
|
+
if (content.includes(unescapedFind)) {
|
|
310
|
+
yield unescapedFind;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const lines = content.split('\n');
|
|
314
|
+
const findLines = unescapedFind.split('\n');
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
317
|
+
const block = lines.slice(i, i + findLines.length).join('\n');
|
|
318
|
+
const unescapedBlock = unescapeString(block);
|
|
319
|
+
|
|
320
|
+
if (unescapedBlock === unescapedFind) {
|
|
321
|
+
yield block;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
|
327
|
+
const trimmedFind = find.trim();
|
|
328
|
+
|
|
329
|
+
if (trimmedFind === find) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (content.includes(trimmedFind)) {
|
|
334
|
+
yield trimmedFind;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const lines = content.split('\n');
|
|
338
|
+
const findLines = find.split('\n');
|
|
339
|
+
|
|
340
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
341
|
+
const block = lines.slice(i, i + findLines.length).join('\n');
|
|
342
|
+
|
|
343
|
+
if (block.trim() === trimmedFind) {
|
|
344
|
+
yield block;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
350
|
+
const findLines = find.split('\n');
|
|
351
|
+
if (findLines.length < 3) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (findLines[findLines.length - 1] === '') {
|
|
356
|
+
findLines.pop();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const contentLines = content.split('\n');
|
|
360
|
+
|
|
361
|
+
const firstLine = findLines[0].trim();
|
|
362
|
+
const lastLine = findLines[findLines.length - 1].trim();
|
|
363
|
+
|
|
364
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
365
|
+
if (contentLines[i].trim() !== firstLine) continue;
|
|
366
|
+
|
|
367
|
+
for (let j = i + 2; j < contentLines.length; j++) {
|
|
368
|
+
if (contentLines[j].trim() === lastLine) {
|
|
369
|
+
const blockLines = contentLines.slice(i, j + 1);
|
|
370
|
+
|
|
371
|
+
if (blockLines.length === findLines.length) {
|
|
372
|
+
let matchingLines = 0;
|
|
373
|
+
let totalNonEmptyLines = 0;
|
|
374
|
+
|
|
375
|
+
for (let k = 1; k < blockLines.length - 1; k++) {
|
|
376
|
+
const blockLine = blockLines[k].trim();
|
|
377
|
+
const findLine = findLines[k].trim();
|
|
378
|
+
|
|
379
|
+
if (blockLine.length > 0 || findLine.length > 0) {
|
|
380
|
+
totalNonEmptyLines++;
|
|
381
|
+
if (blockLine === findLine) {
|
|
382
|
+
matchingLines++;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
totalNonEmptyLines === 0 ||
|
|
389
|
+
matchingLines / totalNonEmptyLines >= 0.5
|
|
390
|
+
) {
|
|
391
|
+
yield blockLines.join('\n');
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
|
402
|
+
let startIndex = 0;
|
|
403
|
+
|
|
404
|
+
while (true) {
|
|
405
|
+
const index = content.indexOf(find, startIndex);
|
|
406
|
+
if (index === -1) break;
|
|
407
|
+
|
|
408
|
+
yield find;
|
|
409
|
+
startIndex = index + find.length;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const ALL_REPLACERS: Replacer[] = [
|
|
414
|
+
SimpleReplacer,
|
|
415
|
+
LineTrimmedReplacer,
|
|
416
|
+
BlockAnchorReplacer,
|
|
417
|
+
WhitespaceNormalizedReplacer,
|
|
418
|
+
IndentationFlexibleReplacer,
|
|
419
|
+
EscapeNormalizedReplacer,
|
|
420
|
+
TrimmedBoundaryReplacer,
|
|
421
|
+
ContextAwareReplacer,
|
|
422
|
+
MultiOccurrenceReplacer,
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
export function replace(
|
|
426
|
+
content: string,
|
|
427
|
+
oldString: string,
|
|
428
|
+
newString: string,
|
|
429
|
+
replaceAll = false,
|
|
430
|
+
): string {
|
|
431
|
+
if (oldString === newString) {
|
|
432
|
+
throw new Error('oldString and newString must be different');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let notFound = true;
|
|
436
|
+
|
|
437
|
+
for (const replacer of ALL_REPLACERS) {
|
|
438
|
+
for (const search of replacer(content, oldString)) {
|
|
439
|
+
const index = content.indexOf(search);
|
|
440
|
+
if (index === -1) continue;
|
|
441
|
+
notFound = false;
|
|
442
|
+
if (replaceAll) {
|
|
443
|
+
return content.replaceAll(search, newString);
|
|
444
|
+
}
|
|
445
|
+
const lastIndex = content.lastIndexOf(search);
|
|
446
|
+
if (index !== lastIndex) continue;
|
|
447
|
+
return (
|
|
448
|
+
content.substring(0, index) +
|
|
449
|
+
newString +
|
|
450
|
+
content.substring(index + search.length)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (notFound) {
|
|
456
|
+
throw new Error('oldString not found in content');
|
|
457
|
+
}
|
|
458
|
+
throw new Error(
|
|
459
|
+
'Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.',
|
|
460
|
+
);
|
|
461
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { tool, type Tool } from 'ai';
|
|
2
|
+
import { z } from 'zod/v3';
|
|
3
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
5
|
+
import DESCRIPTION from './edit.txt' with { type: 'text' };
|
|
6
|
+
import { createToolError, type ToolResponse } from '../error.ts';
|
|
7
|
+
import { replace } from './edit/replacers.ts';
|
|
8
|
+
import { buildWriteArtifact } from './fs/util.ts';
|
|
9
|
+
|
|
10
|
+
export function buildEditTool(projectRoot: string): {
|
|
11
|
+
name: string;
|
|
12
|
+
tool: Tool;
|
|
13
|
+
} {
|
|
14
|
+
const editTool = tool({
|
|
15
|
+
description: DESCRIPTION,
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
filePath: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe(
|
|
20
|
+
'The path to the file to modify (relative to project root or absolute)',
|
|
21
|
+
),
|
|
22
|
+
oldString: z.string().describe('The text to replace'),
|
|
23
|
+
newString: z
|
|
24
|
+
.string()
|
|
25
|
+
.describe(
|
|
26
|
+
'The text to replace it with (must be different from oldString)',
|
|
27
|
+
),
|
|
28
|
+
replaceAll: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.default(false)
|
|
32
|
+
.describe('Replace all occurrences of oldString (default false)'),
|
|
33
|
+
}),
|
|
34
|
+
async execute({
|
|
35
|
+
filePath,
|
|
36
|
+
oldString,
|
|
37
|
+
newString,
|
|
38
|
+
replaceAll: replaceAllFlag = false,
|
|
39
|
+
}: {
|
|
40
|
+
filePath: string;
|
|
41
|
+
oldString: string;
|
|
42
|
+
newString: string;
|
|
43
|
+
replaceAll?: boolean;
|
|
44
|
+
}): Promise<
|
|
45
|
+
ToolResponse<{
|
|
46
|
+
output: string;
|
|
47
|
+
filePath: string;
|
|
48
|
+
artifact: unknown;
|
|
49
|
+
}>
|
|
50
|
+
> {
|
|
51
|
+
if (!filePath || filePath.trim().length === 0) {
|
|
52
|
+
return createToolError(
|
|
53
|
+
'Missing required parameter: filePath',
|
|
54
|
+
'validation',
|
|
55
|
+
{
|
|
56
|
+
parameter: 'filePath',
|
|
57
|
+
value: filePath,
|
|
58
|
+
suggestion: 'Provide a file path to edit',
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (oldString === newString) {
|
|
64
|
+
return createToolError(
|
|
65
|
+
'oldString and newString must be different',
|
|
66
|
+
'validation',
|
|
67
|
+
{
|
|
68
|
+
parameter: 'oldString',
|
|
69
|
+
suggestion: 'Provide different values for oldString and newString',
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const absPath = isAbsolute(filePath)
|
|
75
|
+
? filePath
|
|
76
|
+
: join(projectRoot, filePath);
|
|
77
|
+
const relPath = relative(projectRoot, absPath);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (oldString === '') {
|
|
81
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
82
|
+
await writeFile(absPath, newString, 'utf-8');
|
|
83
|
+
const artifact = await buildWriteArtifact(
|
|
84
|
+
relPath,
|
|
85
|
+
false,
|
|
86
|
+
'',
|
|
87
|
+
newString,
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
output: 'File created successfully.',
|
|
92
|
+
filePath: relPath,
|
|
93
|
+
artifact,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fileStat = await stat(absPath).catch(() => null);
|
|
98
|
+
if (!fileStat) {
|
|
99
|
+
return createToolError(`File ${relPath} not found`, 'not_found', {
|
|
100
|
+
parameter: 'filePath',
|
|
101
|
+
value: relPath,
|
|
102
|
+
suggestion: 'Check the file path exists',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (fileStat.isDirectory()) {
|
|
106
|
+
return createToolError(
|
|
107
|
+
`Path is a directory, not a file: ${relPath}`,
|
|
108
|
+
'validation',
|
|
109
|
+
{
|
|
110
|
+
parameter: 'filePath',
|
|
111
|
+
value: relPath,
|
|
112
|
+
suggestion: 'Provide a path to a file, not a directory',
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const contentOld = await readFile(absPath, 'utf-8');
|
|
118
|
+
let contentNew: string;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
contentNew = replace(
|
|
122
|
+
contentOld,
|
|
123
|
+
oldString,
|
|
124
|
+
newString,
|
|
125
|
+
replaceAllFlag,
|
|
126
|
+
);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const message =
|
|
129
|
+
error instanceof Error ? error.message : String(error);
|
|
130
|
+
return createToolError(message, 'execution', {
|
|
131
|
+
suggestion: message.includes('multiple matches')
|
|
132
|
+
? 'Provide more surrounding context in oldString to uniquely identify the match, or use replaceAll: true'
|
|
133
|
+
: 'Verify the oldString matches the file content exactly',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await writeFile(absPath, contentNew, 'utf-8');
|
|
138
|
+
|
|
139
|
+
const artifact = await buildWriteArtifact(
|
|
140
|
+
relPath,
|
|
141
|
+
true,
|
|
142
|
+
contentOld,
|
|
143
|
+
contentNew,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
output: 'Edit applied successfully.',
|
|
149
|
+
filePath: relPath,
|
|
150
|
+
artifact,
|
|
151
|
+
};
|
|
152
|
+
} catch (error: unknown) {
|
|
153
|
+
return createToolError(
|
|
154
|
+
`Failed to edit file: ${error instanceof Error ? error.message : String(error)}`,
|
|
155
|
+
'execution',
|
|
156
|
+
{
|
|
157
|
+
parameter: 'filePath',
|
|
158
|
+
value: relPath,
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return { name: 'edit', tool: editTool };
|
|
166
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Performs exact string replacements in files.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
|
5
|
+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears in the file.
|
|
6
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
7
|
+
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
|
8
|
+
- The edit will FAIL if `oldString` is found multiple times in the file with an error about multiple matches. Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance.
|
|
9
|
+
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful for renaming a variable.
|
|
10
|
+
- To create a new file, use an empty `oldString` and set `newString` to the file contents.
|