pi-agent-flow 1.0.7 → 1.1.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/README.md +2 -2
- package/agents/audit.md +36 -0
- package/agents/{code.md → build.md} +10 -4
- package/agents/craft.md +37 -0
- package/agents/debug.md +2 -1
- package/agents/{brainstorm.md → ideas.md} +4 -4
- package/agents/scout.md +31 -0
- package/agents.ts +5 -8
- package/batch.ts +1083 -0
- package/config.ts +35 -0
- package/flow.ts +84 -18
- package/hooks.ts +31 -13
- package/index.ts +126 -28
- package/package.json +11 -3
- package/render-utils.ts +3 -3
- package/render.ts +55 -24
- package/runner-events.js +280 -126
- package/types.ts +3 -2
- package/web-tool.ts +663 -0
- package/agents/architect.md +0 -35
- package/agents/explore.md +0 -29
- package/agents/review.md +0 -35
package/batch.ts
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* batch — Unified batch file operations tool.
|
|
3
|
+
*
|
|
4
|
+
* Combines read, write, edit, and delete into a single tool call.
|
|
5
|
+
* Executes operations sequentially with skip-on-failure semantics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { Type } from "@sinclair/typebox";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Schema
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const EditOp = Type.Object({
|
|
18
|
+
f: Type.String({
|
|
19
|
+
description:
|
|
20
|
+
"Exact text to find (oldText). Must be unique in the file. All edits matched against original file, not incrementally.",
|
|
21
|
+
}),
|
|
22
|
+
r: Type.String({ description: "Replacement text (newText)." }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const FileOp = Type.Object({
|
|
26
|
+
o: Type.Union([
|
|
27
|
+
Type.Literal("read"),
|
|
28
|
+
Type.Literal("write"),
|
|
29
|
+
Type.Literal("edit"),
|
|
30
|
+
Type.Literal("delete"),
|
|
31
|
+
]),
|
|
32
|
+
p: Type.String({ description: "Path to the file (relative or absolute)" }),
|
|
33
|
+
c: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
description:
|
|
36
|
+
"Full file content. Creates if new, overwrites if exists. Auto-creates parent dirs. Used with o: 'write'.",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
e: Type.Optional(
|
|
40
|
+
Type.Array(EditOp, {
|
|
41
|
+
description:
|
|
42
|
+
"One or more targeted replacements matched against the original file, not incrementally.",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
s: Type.Optional(
|
|
46
|
+
Type.Number({
|
|
47
|
+
minimum: 1,
|
|
48
|
+
description:
|
|
49
|
+
"1-indexed line number to start reading from (offset). Used with o: 'read'.",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
l: Type.Optional(
|
|
53
|
+
Type.Number({
|
|
54
|
+
minimum: 1,
|
|
55
|
+
description:
|
|
56
|
+
"Maximum number of lines to read (limit). Used with o: 'read'.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const WeavePatchParams = Type.Object({
|
|
62
|
+
o: Type.Array(FileOp, {
|
|
63
|
+
description:
|
|
64
|
+
"Ordered list of file operations. Executed sequentially. On failure, remaining operations are skipped.",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Types
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
interface EditReplacement {
|
|
73
|
+
f: string;
|
|
74
|
+
r: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface FileOpInput {
|
|
78
|
+
o: "read" | "write" | "edit" | "delete";
|
|
79
|
+
p: string;
|
|
80
|
+
c?: string;
|
|
81
|
+
e?: EditReplacement[];
|
|
82
|
+
s?: number;
|
|
83
|
+
l?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface OpResult {
|
|
87
|
+
op: "read" | "write" | "edit" | "delete";
|
|
88
|
+
path: string;
|
|
89
|
+
status: "ok" | "error" | "skipped";
|
|
90
|
+
content?: string;
|
|
91
|
+
bytes?: number;
|
|
92
|
+
blocksChanged?: number;
|
|
93
|
+
totalLines?: number;
|
|
94
|
+
truncated?: boolean;
|
|
95
|
+
nextOffset?: number;
|
|
96
|
+
error?: string;
|
|
97
|
+
hint?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ReadTruncationResult {
|
|
101
|
+
content: string;
|
|
102
|
+
truncated: boolean;
|
|
103
|
+
nextOffset?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Helpers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const MAX_LINES = 2000;
|
|
111
|
+
const MAX_BYTES = 50 * 1024; // 50KB
|
|
112
|
+
|
|
113
|
+
function normalizeToLF(text: string): string {
|
|
114
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function restoreLineEndings(text: string, ending: string): string {
|
|
118
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function detectLineEnding(content: string): string {
|
|
122
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
123
|
+
const lfIdx = content.indexOf("\n");
|
|
124
|
+
if (lfIdx === -1) return "\n";
|
|
125
|
+
if (crlfIdx === -1) return "\n";
|
|
126
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stripBom(content: string): { bom: string; text: string } {
|
|
130
|
+
return content.startsWith("\uFEFF")
|
|
131
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
132
|
+
: { bom: "", text: content };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readWithOffsetLimit(
|
|
136
|
+
content: string,
|
|
137
|
+
offset?: number,
|
|
138
|
+
limit?: number,
|
|
139
|
+
filePath?: string,
|
|
140
|
+
): ReadTruncationResult {
|
|
141
|
+
const allLines = content.split("\n");
|
|
142
|
+
const totalFileLines = allLines.length;
|
|
143
|
+
|
|
144
|
+
// Validate offset
|
|
145
|
+
if (offset !== undefined && offset > totalFileLines) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Determine the start line (convert 1-indexed to 0-indexed)
|
|
152
|
+
const startLine = offset !== undefined ? Math.max(0, offset - 1) : 0;
|
|
153
|
+
|
|
154
|
+
// Determine end line
|
|
155
|
+
let endLine = totalFileLines;
|
|
156
|
+
if (limit !== undefined) {
|
|
157
|
+
endLine = Math.min(startLine + limit, totalFileLines);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let selectedLines = allLines.slice(startLine, endLine);
|
|
161
|
+
let truncated = false;
|
|
162
|
+
let nextOffset: number | undefined;
|
|
163
|
+
|
|
164
|
+
// Apply max-lines cap
|
|
165
|
+
if (selectedLines.length > MAX_LINES) {
|
|
166
|
+
selectedLines = selectedLines.slice(0, MAX_LINES);
|
|
167
|
+
truncated = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Join and check byte size
|
|
171
|
+
let result = selectedLines.join("\n");
|
|
172
|
+
|
|
173
|
+
// If first line alone exceeds byte limit, give a specific hint
|
|
174
|
+
if (selectedLines.length >= 1 && Buffer.byteLength(selectedLines[0], "utf-8") > MAX_BYTES) {
|
|
175
|
+
const startLineDisplay = startLine + 1;
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Line ${startLineDisplay} exceeds limit. Try: batch with o:"read", s:${startLineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Truncate by bytes if needed
|
|
182
|
+
if (Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
|
|
183
|
+
let byteAccum = 0;
|
|
184
|
+
let keepLines = 0;
|
|
185
|
+
for (let i = 0; i < selectedLines.length; i++) {
|
|
186
|
+
byteAccum += Buffer.byteLength(selectedLines[i], "utf-8") + (i > 0 ? 1 : 0); // newline separator between lines
|
|
187
|
+
if (byteAccum > MAX_BYTES) break;
|
|
188
|
+
keepLines = i + 1;
|
|
189
|
+
}
|
|
190
|
+
selectedLines = selectedLines.slice(0, keepLines);
|
|
191
|
+
result = selectedLines.join("\n");
|
|
192
|
+
truncated = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Calculate nextOffset for continuation
|
|
196
|
+
const lastLineRead = startLine + selectedLines.length;
|
|
197
|
+
if (truncated || (limit !== undefined && lastLineRead < totalFileLines)) {
|
|
198
|
+
nextOffset = lastLineRead + 1; // 1-indexed
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Append truncation/continuation hints
|
|
202
|
+
if (truncated) {
|
|
203
|
+
const endDisplay = startLine + selectedLines.length;
|
|
204
|
+
const startDisplay = startLine + 1;
|
|
205
|
+
result += `\n\n[Showing lines ${startDisplay}-${endDisplay} of ${totalFileLines}. Use s=${nextOffset} to continue.]`;
|
|
206
|
+
} else if (limit !== undefined && lastLineRead < totalFileLines) {
|
|
207
|
+
const remaining = totalFileLines - lastLineRead;
|
|
208
|
+
result += `\n\n[${remaining} more lines in file. Use s=${nextOffset} to continue.]`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { content: result, truncated, nextOffset };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function levenshtein(a: string, b: string): number {
|
|
215
|
+
const m = a.length;
|
|
216
|
+
const n = b.length;
|
|
217
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
218
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
219
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
220
|
+
for (let i = 1; i <= m; i++) {
|
|
221
|
+
for (let j = 1; j <= n; j++) {
|
|
222
|
+
dp[i][j] =
|
|
223
|
+
a[i - 1] === b[j - 1]
|
|
224
|
+
? dp[i - 1][j - 1]
|
|
225
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return dp[m][n];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Scan the parent directory (or cwd) for files with similar names.
|
|
233
|
+
* Returns up to 3 suggestions sorted by similarity.
|
|
234
|
+
*/
|
|
235
|
+
export async function suggestSimilarFiles(
|
|
236
|
+
inputPath: string,
|
|
237
|
+
cwd: string,
|
|
238
|
+
): Promise<string[]> {
|
|
239
|
+
const resolved = path.resolve(cwd, inputPath);
|
|
240
|
+
const dir = path.dirname(resolved);
|
|
241
|
+
const target = path.basename(resolved);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
245
|
+
const candidates: { name: string; dist: number }[] = [];
|
|
246
|
+
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const name = entry.name;
|
|
249
|
+
// Skip hidden files and node_modules
|
|
250
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
251
|
+
|
|
252
|
+
const dist = levenshtein(target.toLowerCase(), name.toLowerCase());
|
|
253
|
+
const maxLen = Math.max(target.length, name.length);
|
|
254
|
+
// Only suggest if reasonably similar (within 40% edit distance, or shares prefix)
|
|
255
|
+
if (dist <= Math.ceil(maxLen * 0.4) || name.startsWith(target.slice(0, 3))) {
|
|
256
|
+
candidates.push({ name: entry.isDirectory() ? name + "/" : name, dist });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return candidates
|
|
261
|
+
.sort((a, b) => a.dist - b.dist)
|
|
262
|
+
.slice(0, 3)
|
|
263
|
+
.map((c) => path.join(path.relative(cwd, dir), c.name));
|
|
264
|
+
} catch {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getErrorHint(error: string): string {
|
|
270
|
+
if (error.includes("File not found") || error.includes("file not found"))
|
|
271
|
+
return "Verify the path exists.";
|
|
272
|
+
if (error.includes("Could not find"))
|
|
273
|
+
return "Re-read the file first, then retry with exact f (oldText).";
|
|
274
|
+
if (error.includes("occurrences"))
|
|
275
|
+
return "Add more surrounding context to make oldText unique.";
|
|
276
|
+
if (error.includes("overlap"))
|
|
277
|
+
return "Merge overlapping edits into one.";
|
|
278
|
+
if (error.includes("No changes"))
|
|
279
|
+
return "File already has this content. No edit needed.";
|
|
280
|
+
if (error.includes("Path traversal"))
|
|
281
|
+
return "Use a path within the working directory.";
|
|
282
|
+
if (error.includes("is not readable") || error.includes("not readable"))
|
|
283
|
+
return "Check file permissions.";
|
|
284
|
+
if (error.includes("ENOENT") || error.includes("no such file"))
|
|
285
|
+
return "Verify the path exists.";
|
|
286
|
+
if (error.includes("is beyond end of file"))
|
|
287
|
+
return "Use a smaller offset within the file length.";
|
|
288
|
+
return "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Fuzzy matching (simplified)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function normalizeForMatch(text: string): string {
|
|
296
|
+
return text
|
|
297
|
+
.split("\n")
|
|
298
|
+
.map((line) => line.trimEnd())
|
|
299
|
+
.join("\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildPositionMap(original: string): number[] {
|
|
303
|
+
const normalized = normalizeForMatch(original);
|
|
304
|
+
const map: number[] = new Array(normalized.length + 1);
|
|
305
|
+
let oi = 0;
|
|
306
|
+
let ni = 0;
|
|
307
|
+
|
|
308
|
+
while (oi < original.length && ni < normalized.length) {
|
|
309
|
+
map[ni] = oi;
|
|
310
|
+
if (original[oi] === normalized[ni]) {
|
|
311
|
+
oi++;
|
|
312
|
+
ni++;
|
|
313
|
+
} else {
|
|
314
|
+
oi++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
while (ni < normalized.length) {
|
|
319
|
+
map[ni] = oi;
|
|
320
|
+
ni++;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
map[normalized.length] = original.length;
|
|
324
|
+
return map;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function fuzzyFindText(
|
|
328
|
+
content: string,
|
|
329
|
+
oldText: string,
|
|
330
|
+
): { found: boolean; index: number; matchLength: number; isExact: boolean } {
|
|
331
|
+
// Try exact match first
|
|
332
|
+
const exactIndex = content.indexOf(oldText);
|
|
333
|
+
if (exactIndex !== -1) {
|
|
334
|
+
return { found: true, index: exactIndex, matchLength: oldText.length, isExact: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Try trimmed match, returning original indices
|
|
338
|
+
const normalizedContent = normalizeForMatch(content);
|
|
339
|
+
const normalizedOld = normalizeForMatch(oldText);
|
|
340
|
+
const fuzzyIndex = normalizedContent.indexOf(normalizedOld);
|
|
341
|
+
if (fuzzyIndex !== -1) {
|
|
342
|
+
const map = buildPositionMap(content);
|
|
343
|
+
const originalStart = map[fuzzyIndex];
|
|
344
|
+
const originalEnd = map[fuzzyIndex + normalizedOld.length];
|
|
345
|
+
return { found: true, index: originalStart, matchLength: originalEnd - originalStart, isExact: false };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { found: false, index: -1, matchLength: 0, isExact: false };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function countOccurrences(content: string, oldText: string): number {
|
|
352
|
+
const normalizedContent = normalizeForMatch(content);
|
|
353
|
+
const normalizedOld = normalizeForMatch(oldText);
|
|
354
|
+
let count = 0;
|
|
355
|
+
let pos = 0;
|
|
356
|
+
while (true) {
|
|
357
|
+
const idx = normalizedContent.indexOf(normalizedOld, pos);
|
|
358
|
+
if (idx === -1) break;
|
|
359
|
+
count++;
|
|
360
|
+
pos = idx + normalizedOld.length;
|
|
361
|
+
}
|
|
362
|
+
return count;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function countExactOccurrences(content: string, oldText: string): number {
|
|
366
|
+
let count = 0;
|
|
367
|
+
let pos = 0;
|
|
368
|
+
while (true) {
|
|
369
|
+
const idx = content.indexOf(oldText, pos);
|
|
370
|
+
if (idx === -1) break;
|
|
371
|
+
count++;
|
|
372
|
+
pos = idx + oldText.length;
|
|
373
|
+
}
|
|
374
|
+
return count;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Edit logic
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Apply a fuzzy edit, preserving trailing whitespace from the original matched
|
|
383
|
+
* text that wasn't explicitly present in the oldText.
|
|
384
|
+
*
|
|
385
|
+
* This prevents normalizeForMatch from stripping trailing whitespace on lines
|
|
386
|
+
* that are being edited when fuzzy matching is used.
|
|
387
|
+
*/
|
|
388
|
+
function applyFuzzyEdit(
|
|
389
|
+
content: string,
|
|
390
|
+
matchIndex: number,
|
|
391
|
+
matchLength: number,
|
|
392
|
+
oldText: string,
|
|
393
|
+
newText: string,
|
|
394
|
+
): string {
|
|
395
|
+
const before = content.substring(0, matchIndex);
|
|
396
|
+
const after = content.substring(matchIndex + matchLength);
|
|
397
|
+
const matched = content.substring(matchIndex, matchIndex + matchLength);
|
|
398
|
+
|
|
399
|
+
const matchedLines = matched.split("\n");
|
|
400
|
+
const oldLines = oldText.split("\n");
|
|
401
|
+
const newLines = newText.split("\n");
|
|
402
|
+
|
|
403
|
+
const resultLines: string[] = [];
|
|
404
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
405
|
+
const newLine = newLines[i];
|
|
406
|
+
const oldLine = oldLines[i] ?? "";
|
|
407
|
+
const matchedLine = matchedLines[i] ?? "";
|
|
408
|
+
|
|
409
|
+
const oldTrailing = oldLine.length - oldLine.trimEnd().length;
|
|
410
|
+
const matchedTrailing = matchedLine.length - matchedLine.trimEnd().length;
|
|
411
|
+
|
|
412
|
+
if (matchedTrailing > oldTrailing) {
|
|
413
|
+
const extraStart = matchedLine.trimEnd().length + oldTrailing;
|
|
414
|
+
resultLines.push(newLine + matchedLine.slice(extraStart));
|
|
415
|
+
} else {
|
|
416
|
+
resultLines.push(newLine);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return before + resultLines.join("\n") + after;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function applyEdits(
|
|
424
|
+
content: string,
|
|
425
|
+
edits: EditReplacement[],
|
|
426
|
+
filePath: string,
|
|
427
|
+
): { newContent: string; blocksChanged: number } {
|
|
428
|
+
const normalizedEdits = edits.map((e) => ({
|
|
429
|
+
oldText: normalizeToLF(e.f),
|
|
430
|
+
newText: normalizeToLF(e.r),
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
// Validate non-empty
|
|
434
|
+
for (let i = 0; i < normalizedEdits.length; i++) {
|
|
435
|
+
if (normalizedEdits[i].oldText.length === 0) {
|
|
436
|
+
throw new Error(`edits[${i}].f (oldText) must not be empty in ${filePath}.`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const baseContent = content;
|
|
441
|
+
|
|
442
|
+
// Match all edits
|
|
443
|
+
interface MatchResult {
|
|
444
|
+
editIndex: number;
|
|
445
|
+
matchIndex: number;
|
|
446
|
+
matchLength: number;
|
|
447
|
+
newText: string;
|
|
448
|
+
oldText: string;
|
|
449
|
+
isExact: boolean;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const matchedEdits: MatchResult[] = [];
|
|
453
|
+
for (let i = 0; i < normalizedEdits.length; i++) {
|
|
454
|
+
const edit = normalizedEdits[i];
|
|
455
|
+
const matchResult = fuzzyFindText(baseContent, edit.oldText);
|
|
456
|
+
|
|
457
|
+
if (!matchResult.found) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
edits.length === 1
|
|
460
|
+
? `Could not find the exact text in ${filePath}. The old text must match exactly including all whitespace and newlines.`
|
|
461
|
+
: `Could not find edits[${i}] in ${filePath}. The f (oldText) must match exactly including all whitespace and newlines.`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const occurrences = matchResult.isExact
|
|
466
|
+
? countExactOccurrences(baseContent, edit.oldText)
|
|
467
|
+
: countOccurrences(baseContent, edit.oldText);
|
|
468
|
+
if (occurrences > 1) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
edits.length === 1
|
|
471
|
+
? `Found ${occurrences} occurrences of the text in ${filePath}. The text must be unique. Please provide more context to make it unique.`
|
|
472
|
+
: `Found ${occurrences} occurrences of edits[${i}] in ${filePath}. Each f (oldText) must be unique. Please provide more context to make it unique.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
matchedEdits.push({
|
|
477
|
+
editIndex: i,
|
|
478
|
+
matchIndex: matchResult.index,
|
|
479
|
+
matchLength: matchResult.matchLength,
|
|
480
|
+
newText: edit.newText,
|
|
481
|
+
oldText: edit.oldText,
|
|
482
|
+
isExact: matchResult.isExact,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Sort by position (ascending)
|
|
487
|
+
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
|
|
488
|
+
|
|
489
|
+
// Check for overlaps
|
|
490
|
+
for (let i = 1; i < matchedEdits.length; i++) {
|
|
491
|
+
const previous = matchedEdits[i - 1];
|
|
492
|
+
const current = matchedEdits[i];
|
|
493
|
+
if (previous.matchIndex + previous.matchLength > current.matchIndex) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${filePath}. Merge them into one edit or target disjoint regions.`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Apply edits in reverse order to preserve offsets
|
|
501
|
+
let newContent = baseContent;
|
|
502
|
+
for (let i = matchedEdits.length - 1; i >= 0; i--) {
|
|
503
|
+
const edit = matchedEdits[i];
|
|
504
|
+
if (edit.isExact) {
|
|
505
|
+
newContent =
|
|
506
|
+
newContent.substring(0, edit.matchIndex) +
|
|
507
|
+
edit.newText +
|
|
508
|
+
newContent.substring(edit.matchIndex + edit.matchLength);
|
|
509
|
+
} else {
|
|
510
|
+
newContent = applyFuzzyEdit(
|
|
511
|
+
newContent,
|
|
512
|
+
edit.matchIndex,
|
|
513
|
+
edit.matchLength,
|
|
514
|
+
edit.oldText,
|
|
515
|
+
edit.newText,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (baseContent === newContent) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
edits.length === 1
|
|
523
|
+
? `No changes made to ${filePath}. The replacement produced identical content.`
|
|
524
|
+
: `No changes made to ${filePath}. The replacements produced identical content.`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
newContent,
|
|
530
|
+
blocksChanged: matchedEdits.length,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Path validation
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
function expandTilde(inputPath: string): string {
|
|
539
|
+
if (inputPath === "~") return os.homedir();
|
|
540
|
+
if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
|
|
541
|
+
return inputPath;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function isWithinDirectory(child: string, parent: string): boolean {
|
|
545
|
+
if (process.platform === "win32") {
|
|
546
|
+
const childLower = child.toLowerCase();
|
|
547
|
+
const parentLower = parent.toLowerCase();
|
|
548
|
+
if (childLower === parentLower) return true;
|
|
549
|
+
const sep = path.win32.sep;
|
|
550
|
+
const prefix = parentLower.endsWith(sep) ? parentLower : parentLower + sep;
|
|
551
|
+
return childLower.startsWith(prefix);
|
|
552
|
+
}
|
|
553
|
+
if (child === parent) return true;
|
|
554
|
+
if (parent === "/") return child.startsWith("/");
|
|
555
|
+
return child.startsWith(parent + path.sep);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function validatePath(inputPath: string, cwd: string): Promise<string> {
|
|
559
|
+
const expandedPath = expandTilde(inputPath);
|
|
560
|
+
const resolved = path.resolve(cwd, expandedPath);
|
|
561
|
+
const normalizedResolved = path.normalize(resolved);
|
|
562
|
+
const normalizedCwd = path.normalize(cwd);
|
|
563
|
+
if (
|
|
564
|
+
normalizedResolved !== normalizedCwd &&
|
|
565
|
+
!isWithinDirectory(normalizedResolved, normalizedCwd)
|
|
566
|
+
) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`Path traversal detected: ${inputPath} resolves outside working directory.`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Resolve cwd and file symlinks to prevent traversal via symlink targets.
|
|
573
|
+
// cwd must also be resolved (e.g. macOS /var -> /private/var).
|
|
574
|
+
const realCwd = await fs.realpath(cwd);
|
|
575
|
+
|
|
576
|
+
let realPath: string;
|
|
577
|
+
try {
|
|
578
|
+
realPath = await fs.realpath(resolved);
|
|
579
|
+
} catch {
|
|
580
|
+
const normalizedRealCwd = path.normalize(realCwd);
|
|
581
|
+
|
|
582
|
+
// Check if the final component is a broken symlink pointing outside cwd.
|
|
583
|
+
try {
|
|
584
|
+
const lstat = await fs.lstat(resolved);
|
|
585
|
+
if (lstat.isSymbolicLink()) {
|
|
586
|
+
const linkTarget = await fs.readlink(resolved);
|
|
587
|
+
const realLinkDir = await fs.realpath(path.dirname(resolved));
|
|
588
|
+
const resolvedTarget = path.resolve(realLinkDir, linkTarget);
|
|
589
|
+
const normalizedTarget = path.normalize(resolvedTarget);
|
|
590
|
+
if (
|
|
591
|
+
normalizedTarget !== normalizedRealCwd &&
|
|
592
|
+
!isWithinDirectory(normalizedTarget, normalizedRealCwd)
|
|
593
|
+
) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Path traversal detected: ${inputPath} symlink points outside working directory.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
return resolved;
|
|
599
|
+
}
|
|
600
|
+
} catch (lstatErr: any) {
|
|
601
|
+
if (lstatErr.code !== "ENOENT") throw lstatErr;
|
|
602
|
+
// Not a symlink, proceed to ancestor fallback
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// File doesn't exist yet (e.g. write creates new file).
|
|
606
|
+
// Walk up to the nearest existing ancestor and validate it is within realCwd.
|
|
607
|
+
let ancestor = path.dirname(resolved);
|
|
608
|
+
let ancestorReal: string | null = null;
|
|
609
|
+
while (ancestor && ancestor !== path.dirname(ancestor)) {
|
|
610
|
+
try {
|
|
611
|
+
ancestorReal = await fs.realpath(ancestor);
|
|
612
|
+
break;
|
|
613
|
+
} catch {
|
|
614
|
+
ancestor = path.dirname(ancestor);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (!ancestorReal) {
|
|
618
|
+
throw new Error(`Path not found: ${inputPath}`);
|
|
619
|
+
}
|
|
620
|
+
const normalizedAncestor = path.normalize(ancestorReal);
|
|
621
|
+
if (
|
|
622
|
+
normalizedAncestor !== normalizedRealCwd &&
|
|
623
|
+
!isWithinDirectory(normalizedAncestor, normalizedRealCwd)
|
|
624
|
+
) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`Path traversal detected: ${inputPath} ancestor directory is outside working directory.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return resolved;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Validate resolved real path is within realCwd
|
|
633
|
+
const normalizedReal = path.normalize(realPath);
|
|
634
|
+
const normalizedRealCwd = path.normalize(realCwd);
|
|
635
|
+
if (
|
|
636
|
+
normalizedReal !== normalizedRealCwd &&
|
|
637
|
+
!isWithinDirectory(normalizedReal, normalizedRealCwd)
|
|
638
|
+
) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`Path traversal detected: ${inputPath} symlink points outside working directory.`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// If the requested path is a symlink, return the original path so that
|
|
645
|
+
// operations like delete can act on the symlink itself.
|
|
646
|
+
try {
|
|
647
|
+
const lstat = await fs.lstat(resolved);
|
|
648
|
+
if (lstat.isSymbolicLink()) {
|
|
649
|
+
return resolved;
|
|
650
|
+
}
|
|
651
|
+
} catch {
|
|
652
|
+
// ignore
|
|
653
|
+
}
|
|
654
|
+
return realPath;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// prepareArguments shim
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Normalize a single operation object from any legacy format to the new
|
|
663
|
+
* single-letter canonical form: { o, p, c, e, s, l }.
|
|
664
|
+
*/
|
|
665
|
+
function normalizeOp(raw: Record<string, unknown>): Record<string, unknown> {
|
|
666
|
+
const op: Record<string, unknown> = {};
|
|
667
|
+
|
|
668
|
+
// Map operation type
|
|
669
|
+
op.o = raw.o ?? raw.op ?? (raw.c != null || raw.content != null ? "write" : (raw.e != null || raw.edits != null ? "edit" : "read"));
|
|
670
|
+
|
|
671
|
+
// Map path
|
|
672
|
+
op.p = raw.p ?? raw.path;
|
|
673
|
+
|
|
674
|
+
// Map content
|
|
675
|
+
if (raw.c !== undefined) op.c = raw.c;
|
|
676
|
+
else if (raw.content !== undefined) op.c = raw.content;
|
|
677
|
+
|
|
678
|
+
// Map edits
|
|
679
|
+
let editsRaw = raw.e ?? raw.edits;
|
|
680
|
+
if (typeof editsRaw === "string") {
|
|
681
|
+
try { editsRaw = JSON.parse(editsRaw); } catch { /* ignore */ }
|
|
682
|
+
}
|
|
683
|
+
if (Array.isArray(editsRaw)) {
|
|
684
|
+
op.e = editsRaw.map((e: unknown) => {
|
|
685
|
+
if (!e || typeof e !== "object") return e;
|
|
686
|
+
const edit = e as Record<string, unknown>;
|
|
687
|
+
return { f: edit.f ?? edit.oldText, r: edit.r ?? edit.newText };
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Map offset / limit
|
|
692
|
+
if (raw.s !== undefined) op.s = raw.s;
|
|
693
|
+
else if (raw.offset !== undefined) op.s = raw.offset;
|
|
694
|
+
if (raw.l !== undefined) op.l = raw.l;
|
|
695
|
+
else if (raw.limit !== undefined) op.l = raw.limit;
|
|
696
|
+
|
|
697
|
+
return op;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Normalize input arguments to the canonical { o: [...] } shape.
|
|
702
|
+
* Handles legacy formats, bare arrays, and single-operation shorthands.
|
|
703
|
+
* Always returns { o: FileOpInput[] } to match WeavePatchParams schema.
|
|
704
|
+
*/
|
|
705
|
+
function prepareArguments(input: unknown): { o: unknown[] } | unknown {
|
|
706
|
+
if (!input || typeof input !== "object") return { o: [] };
|
|
707
|
+
|
|
708
|
+
const args = input as Record<string, unknown>;
|
|
709
|
+
|
|
710
|
+
// Handle legacy top-level format: { path, oldText, newText }
|
|
711
|
+
if (
|
|
712
|
+
typeof args.oldText === "string" &&
|
|
713
|
+
typeof args.newText === "string" &&
|
|
714
|
+
typeof args.path === "string"
|
|
715
|
+
) {
|
|
716
|
+
return {
|
|
717
|
+
o: [
|
|
718
|
+
normalizeOp({
|
|
719
|
+
o: "edit",
|
|
720
|
+
p: args.path,
|
|
721
|
+
e: [{ oldText: args.oldText, newText: args.newText }],
|
|
722
|
+
}),
|
|
723
|
+
],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Extract ops array — canonical { o: [...] }, legacy { op: [...] }, legacy { operations: [...] }, or bare array
|
|
728
|
+
let opsArray: unknown[];
|
|
729
|
+
if (Array.isArray(args.o)) {
|
|
730
|
+
opsArray = args.o;
|
|
731
|
+
} else if (Array.isArray(args.op)) {
|
|
732
|
+
opsArray = args.op;
|
|
733
|
+
} else if (Array.isArray(args.operations)) {
|
|
734
|
+
opsArray = args.operations;
|
|
735
|
+
} else if (Array.isArray(args)) {
|
|
736
|
+
opsArray = args;
|
|
737
|
+
} else if (typeof args.p === "string" || typeof args.path === "string") {
|
|
738
|
+
// Single-operation shorthand: { p: "...", o: "read" }
|
|
739
|
+
opsArray = [args];
|
|
740
|
+
} else {
|
|
741
|
+
return { o: [] };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Normalize each operation to single-letter form
|
|
745
|
+
return {
|
|
746
|
+
o: opsArray.map((op: unknown) => {
|
|
747
|
+
if (!op || typeof op !== "object") return op;
|
|
748
|
+
return normalizeOp(op as Record<string, unknown>);
|
|
749
|
+
}),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Main execute function
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
async function executeOperations(
|
|
758
|
+
operations: FileOpInput[],
|
|
759
|
+
cwd: string,
|
|
760
|
+
signal?: AbortSignal,
|
|
761
|
+
): Promise<{ summary: string; contentText: string; results: OpResult[] }> {
|
|
762
|
+
const results: OpResult[] = [];
|
|
763
|
+
let failed = false;
|
|
764
|
+
|
|
765
|
+
const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
|
|
766
|
+
const errors: { path: string; op: string; message: string; hint?: string }[] = [];
|
|
767
|
+
const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
|
|
768
|
+
|
|
769
|
+
for (const op of operations) {
|
|
770
|
+
if (signal?.aborted) {
|
|
771
|
+
results.push({ op: op.o, path: op.p, status: "skipped", error: "Operation aborted." });
|
|
772
|
+
counts.skipped++;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (failed) {
|
|
777
|
+
results.push({ op: op.o, path: op.p, status: "skipped" });
|
|
778
|
+
counts.skipped++;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
const resolvedPath = await validatePath(op.p, cwd);
|
|
784
|
+
|
|
785
|
+
switch (op.o) {
|
|
786
|
+
case "read": {
|
|
787
|
+
// Access check before reading
|
|
788
|
+
try {
|
|
789
|
+
await fs.access(resolvedPath);
|
|
790
|
+
} catch {
|
|
791
|
+
throw new Error(`File not found: ${op.p}`);
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
await fs.access(resolvedPath, fs.constants.R_OK);
|
|
795
|
+
} catch {
|
|
796
|
+
throw new Error(`File not readable: ${op.p}`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const rawContent = await fs.readFile(resolvedPath, "utf-8");
|
|
800
|
+
const { text } = stripBom(rawContent);
|
|
801
|
+
const allLines = text.split("\n");
|
|
802
|
+
const totalFileLines = allLines.length;
|
|
803
|
+
|
|
804
|
+
const { content: readContent, truncated, nextOffset } =
|
|
805
|
+
readWithOffsetLimit(text, op.s, op.l, op.p);
|
|
806
|
+
|
|
807
|
+
if (truncated || (op.l !== undefined && (op.s ?? 1) - 1 + op.l < totalFileLines)) {
|
|
808
|
+
const shownLines = truncated
|
|
809
|
+
? (op.l !== undefined
|
|
810
|
+
? Math.min(op.l, MAX_LINES)
|
|
811
|
+
: MAX_LINES)
|
|
812
|
+
: op.l!;
|
|
813
|
+
truncatedFiles.push({
|
|
814
|
+
path: op.p,
|
|
815
|
+
shown: shownLines,
|
|
816
|
+
total: totalFileLines,
|
|
817
|
+
nextOffset,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
results.push({
|
|
822
|
+
op: "read",
|
|
823
|
+
path: op.p,
|
|
824
|
+
status: "ok",
|
|
825
|
+
content: readContent,
|
|
826
|
+
totalLines: totalFileLines,
|
|
827
|
+
truncated: truncated || undefined,
|
|
828
|
+
nextOffset,
|
|
829
|
+
});
|
|
830
|
+
counts.read++;
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
case "write": {
|
|
835
|
+
if (!op.c && op.c !== "") {
|
|
836
|
+
throw new Error("c (content) is required for write operations.");
|
|
837
|
+
}
|
|
838
|
+
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
839
|
+
await fs.writeFile(resolvedPath, op.c!, "utf-8");
|
|
840
|
+
results.push({
|
|
841
|
+
op: "write",
|
|
842
|
+
path: op.p,
|
|
843
|
+
status: "ok",
|
|
844
|
+
bytes: Buffer.byteLength(op.c!, "utf-8"),
|
|
845
|
+
});
|
|
846
|
+
counts.write++;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
case "edit": {
|
|
851
|
+
if (!op.e || op.e.length === 0) {
|
|
852
|
+
throw new Error("e (edits) array is required for edit operations.");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const rawContent = await fs.readFile(resolvedPath, "utf-8");
|
|
856
|
+
const { bom, text: contentWithoutBom } = stripBom(rawContent);
|
|
857
|
+
const originalEnding = detectLineEnding(contentWithoutBom);
|
|
858
|
+
const normalizedContent = normalizeToLF(contentWithoutBom);
|
|
859
|
+
|
|
860
|
+
const { newContent, blocksChanged } = applyEdits(
|
|
861
|
+
normalizedContent,
|
|
862
|
+
op.e,
|
|
863
|
+
op.p,
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
867
|
+
await fs.writeFile(resolvedPath, finalContent, "utf-8");
|
|
868
|
+
|
|
869
|
+
results.push({
|
|
870
|
+
op: "edit",
|
|
871
|
+
path: op.p,
|
|
872
|
+
status: "ok",
|
|
873
|
+
blocksChanged,
|
|
874
|
+
});
|
|
875
|
+
counts.edit++;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
case "delete": {
|
|
880
|
+
let stat;
|
|
881
|
+
try {
|
|
882
|
+
stat = await fs.lstat(resolvedPath);
|
|
883
|
+
} catch (err: any) {
|
|
884
|
+
if (err.code === "ENOENT") {
|
|
885
|
+
throw new Error(`File not found: ${op.p}`);
|
|
886
|
+
}
|
|
887
|
+
throw err;
|
|
888
|
+
}
|
|
889
|
+
if (stat.isDirectory()) {
|
|
890
|
+
throw new Error(`Cannot delete directory: ${op.p}. Use a recursive removal tool or delete files individually.`);
|
|
891
|
+
}
|
|
892
|
+
await fs.unlink(resolvedPath);
|
|
893
|
+
results.push({ op: "delete", path: op.p, status: "ok" });
|
|
894
|
+
counts.delete++;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
default:
|
|
899
|
+
throw new Error(`Unknown operation type: ${op.o}`);
|
|
900
|
+
}
|
|
901
|
+
} catch (err) {
|
|
902
|
+
failed = true;
|
|
903
|
+
counts.error++;
|
|
904
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
905
|
+
|
|
906
|
+
// Enrich file-not-found errors with fuzzy filename suggestions
|
|
907
|
+
let hint = getErrorHint(message);
|
|
908
|
+
if (
|
|
909
|
+
message.includes("File not found") ||
|
|
910
|
+
message.includes("file not found") ||
|
|
911
|
+
message.includes("ENOENT") ||
|
|
912
|
+
message.includes("no such file")
|
|
913
|
+
) {
|
|
914
|
+
const suggestions = await suggestSimilarFiles(op.p, cwd);
|
|
915
|
+
if (suggestions.length > 0) {
|
|
916
|
+
hint += ` Did you mean: ${suggestions.join(", ")}?`;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
errors.push({ path: op.p, op: op.o, message, hint });
|
|
921
|
+
results.push({
|
|
922
|
+
op: op.o,
|
|
923
|
+
path: op.p,
|
|
924
|
+
status: "error",
|
|
925
|
+
error: message,
|
|
926
|
+
hint,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
}
|
|
931
|
+
// Build the enhanced summary and content text
|
|
932
|
+
const summary = buildSummary(counts, errors, truncatedFiles);
|
|
933
|
+
const contentText = buildContentText(summary, results);
|
|
934
|
+
|
|
935
|
+
return { summary, contentText, results };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function buildSummary(
|
|
939
|
+
counts: { read: number; write: number; edit: number; delete: number; error: number; skipped: number },
|
|
940
|
+
errors: { path: string; op: string; message: string; hint?: string }[],
|
|
941
|
+
truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[],
|
|
942
|
+
): string {
|
|
943
|
+
const totalSuccess =
|
|
944
|
+
counts.read + counts.write + counts.edit + counts.delete;
|
|
945
|
+
const totalOps = totalSuccess + counts.error + counts.skipped;
|
|
946
|
+
|
|
947
|
+
const parts: string[] = [];
|
|
948
|
+
|
|
949
|
+
// Build the success breakdown
|
|
950
|
+
const successParts: string[] = [];
|
|
951
|
+
if (counts.read > 0)
|
|
952
|
+
successParts.push(
|
|
953
|
+
`${counts.read} read${counts.read > 1 ? "s" : ""}`,
|
|
954
|
+
);
|
|
955
|
+
if (counts.write > 0)
|
|
956
|
+
successParts.push(
|
|
957
|
+
`${counts.write} write${counts.write > 1 ? "s" : ""}`,
|
|
958
|
+
);
|
|
959
|
+
if (counts.edit > 0)
|
|
960
|
+
successParts.push(
|
|
961
|
+
`${counts.edit} edit${counts.edit > 1 ? "s" : ""}`,
|
|
962
|
+
);
|
|
963
|
+
if (counts.delete > 0)
|
|
964
|
+
successParts.push(
|
|
965
|
+
`${counts.delete} delete${counts.delete > 1 ? "s" : ""}`,
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
if (counts.error === 0) {
|
|
969
|
+
// All success
|
|
970
|
+
parts.push(`✓ ${totalOps} operations: ${successParts.join(", ")}`);
|
|
971
|
+
} else {
|
|
972
|
+
// Mixed success/failure
|
|
973
|
+
parts.push(
|
|
974
|
+
`✗ ${counts.error} failed${counts.skipped > 0 ? `, ${counts.skipped} skipped` : ""}`,
|
|
975
|
+
);
|
|
976
|
+
if (totalSuccess > 0) {
|
|
977
|
+
parts.push(` ✓ ${successParts.join(", ")} ok`);
|
|
978
|
+
}
|
|
979
|
+
for (const err of errors) {
|
|
980
|
+
const hint = err.hint ?? "";
|
|
981
|
+
const hintSuffix = hint ? ` — ${hint}` : "";
|
|
982
|
+
parts.push(` ✗ ${err.op} ${err.path}: ${err.message}${hintSuffix}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Truncation warnings
|
|
987
|
+
for (const tf of truncatedFiles) {
|
|
988
|
+
if (tf.nextOffset) {
|
|
989
|
+
parts.push(
|
|
990
|
+
` ⚠ ${tf.path} truncated (${tf.shown}/${tf.total} lines) — use s=${tf.nextOffset}`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return parts.join("\n");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function buildContentText(summary: string, results: OpResult[]): string {
|
|
999
|
+
const sections: string[] = [summary];
|
|
1000
|
+
|
|
1001
|
+
for (const r of results) {
|
|
1002
|
+
if (r.op === "read" && r.status === "ok" && r.content) {
|
|
1003
|
+
const lineInfo = r.totalLines !== undefined ? ` (${r.totalLines} lines)` : "";
|
|
1004
|
+
sections.push(`\n--- ${r.path}${lineInfo} ---\n${r.content}`);
|
|
1005
|
+
} else if (r.op === "edit" && r.status === "ok") {
|
|
1006
|
+
const blockInfo = r.blocksChanged !== undefined ? `${r.blocksChanged} block${r.blocksChanged > 1 ? "s" : ""}` : "";
|
|
1007
|
+
sections.push(`\n--- edit: ${r.path} (${blockInfo}) ---`);
|
|
1008
|
+
} else if (r.op === "write" && r.status === "ok") {
|
|
1009
|
+
sections.push(`\n--- write: ${r.path} (${r.bytes ?? 0} bytes) ---`);
|
|
1010
|
+
} else if (r.op === "delete" && r.status === "ok") {
|
|
1011
|
+
sections.push(`\n--- delete: ${r.path} ---`);
|
|
1012
|
+
} else if (r.status === "error") {
|
|
1013
|
+
sections.push(`\n--- ${r.op}: ${r.path} ---\nError: ${r.error}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return sections.join("");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// ---------------------------------------------------------------------------
|
|
1021
|
+
// Tool definition factory
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
|
|
1024
|
+
export function createBatchTool() {
|
|
1025
|
+
return {
|
|
1026
|
+
name: "batch",
|
|
1027
|
+
label: "batch",
|
|
1028
|
+
description: [
|
|
1029
|
+
"Batch file operations — run multiple read, write, edit, or delete ops in a single call.",
|
|
1030
|
+
"Each operation is independent: edits are matched against the current on-disk file, not against prior operations in the same call.",
|
|
1031
|
+
"Operations execute sequentially in array order; on failure, remaining operations are skipped.",
|
|
1032
|
+
"Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
|
|
1033
|
+
"Best for cross-cutting changes, multi-file refactors, or mixing reads with writes across several files.",
|
|
1034
|
+
].join("\n"),
|
|
1035
|
+
promptSnippet: "Batch file operations — run multiple read/write/edit/delete ops in one call",
|
|
1036
|
+
promptGuidelines: [
|
|
1037
|
+
"Use batch to perform multiple file operations in a single call rather than separate tool calls.",
|
|
1038
|
+
"Prefer batch when touching 2+ files or mixing creates, edits, reads, and deletes.",
|
|
1039
|
+
"Each operation is independent — edits match the on-disk file, not prior ops in the same call.",
|
|
1040
|
+
"For single-file edits, the edit tool is fine; batch shines for cross-cutting and multi-file work.",
|
|
1041
|
+
],
|
|
1042
|
+
parameters: WeavePatchParams,
|
|
1043
|
+
prepareArguments: prepareArguments,
|
|
1044
|
+
|
|
1045
|
+
async execute(
|
|
1046
|
+
_toolCallId: string,
|
|
1047
|
+
input: unknown,
|
|
1048
|
+
signal: AbortSignal | undefined,
|
|
1049
|
+
_onUpdate: unknown,
|
|
1050
|
+
ctx: { cwd: string },
|
|
1051
|
+
) {
|
|
1052
|
+
const prepared = prepareArguments(input);
|
|
1053
|
+
// prepareArguments always returns { o: [...] }, but handle
|
|
1054
|
+
// legacy bare arrays for backward compatibility
|
|
1055
|
+
const ops = Array.isArray(prepared)
|
|
1056
|
+
? prepared as FileOpInput[]
|
|
1057
|
+
: (prepared as { o: FileOpInput[] }).o;
|
|
1058
|
+
|
|
1059
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
1060
|
+
return {
|
|
1061
|
+
content: [
|
|
1062
|
+
{ type: "text", text: "Error: o array is required and must not be empty." },
|
|
1063
|
+
],
|
|
1064
|
+
isError: true,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (signal?.aborted) {
|
|
1069
|
+
return {
|
|
1070
|
+
content: [{ type: "text", text: "Operation aborted." }],
|
|
1071
|
+
isError: true,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const { contentText, results } = await executeOperations(ops, ctx.cwd, signal);
|
|
1076
|
+
|
|
1077
|
+
return {
|
|
1078
|
+
content: [{ type: "text", text: contentText }],
|
|
1079
|
+
details: { results },
|
|
1080
|
+
};
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
}
|