pi-agent-flow 1.2.4 → 1.2.5

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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * batch — fuzzy matching and edit application.
3
+ *
4
+ * Normalises trailing whitespace to allow inexact text matches, then applies
5
+ * one or more edits to a file's content while preserving offsets.
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 { EditReplacement } from "./constants.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Normalisation helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export function normalizeToLF(text: string): string {
18
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
19
+ }
20
+
21
+ export function restoreLineEndings(text: string, ending: string): string {
22
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
23
+ }
24
+
25
+ export function detectLineEnding(content: string): string {
26
+ const crlfIdx = content.indexOf("\r\n");
27
+ const lfIdx = content.indexOf("\n");
28
+ if (lfIdx === -1) return "\n";
29
+ if (crlfIdx === -1) return "\n";
30
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
31
+ }
32
+
33
+ export function stripBom(content: string): { bom: string; text: string } {
34
+ return content.startsWith("\uFEFF")
35
+ ? { bom: "\uFEFF", text: content.slice(1) }
36
+ : { bom: "", text: content };
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Fuzzy matching
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function normalizeForMatch(text: string): string {
44
+ return text
45
+ .split("\n")
46
+ .map((line) => line.trimEnd())
47
+ .join("\n");
48
+ }
49
+
50
+ function buildPositionMap(original: string): number[] {
51
+ const normalized = normalizeForMatch(original);
52
+ const map: number[] = new Array(normalized.length + 1);
53
+ let oi = 0;
54
+ let ni = 0;
55
+
56
+ while (oi < original.length && ni < normalized.length) {
57
+ map[ni] = oi;
58
+ if (original[oi] === normalized[ni]) {
59
+ oi++;
60
+ ni++;
61
+ } else {
62
+ oi++;
63
+ }
64
+ }
65
+
66
+ while (ni < normalized.length) {
67
+ map[ni] = oi;
68
+ ni++;
69
+ }
70
+
71
+ map[normalized.length] = original.length;
72
+ return map;
73
+ }
74
+
75
+ export function fuzzyFindText(
76
+ content: string,
77
+ oldText: string,
78
+ ): { found: boolean; index: number; matchLength: number; isExact: boolean } {
79
+ // Try exact match first
80
+ const exactIndex = content.indexOf(oldText);
81
+ if (exactIndex !== -1) {
82
+ return { found: true, index: exactIndex, matchLength: oldText.length, isExact: true };
83
+ }
84
+
85
+ // Try trimmed match, returning original indices
86
+ const normalizedContent = normalizeForMatch(content);
87
+ const normalizedOld = normalizeForMatch(oldText);
88
+ const fuzzyIndex = normalizedContent.indexOf(normalizedOld);
89
+ if (fuzzyIndex !== -1) {
90
+ const map = buildPositionMap(content);
91
+ const originalStart = map[fuzzyIndex];
92
+ const originalEnd = map[fuzzyIndex + normalizedOld.length];
93
+ return { found: true, index: originalStart, matchLength: originalEnd - originalStart, isExact: false };
94
+ }
95
+
96
+ return { found: false, index: -1, matchLength: 0, isExact: false };
97
+ }
98
+
99
+ function countOccurrences(content: string, oldText: string): number {
100
+ const normalizedContent = normalizeForMatch(content);
101
+ const normalizedOld = normalizeForMatch(oldText);
102
+ let count = 0;
103
+ let pos = 0;
104
+ while (true) {
105
+ const idx = normalizedContent.indexOf(normalizedOld, pos);
106
+ if (idx === -1) break;
107
+ count++;
108
+ pos = idx + normalizedOld.length;
109
+ }
110
+ return count;
111
+ }
112
+
113
+ function countExactOccurrences(content: string, oldText: string): number {
114
+ let count = 0;
115
+ let pos = 0;
116
+ while (true) {
117
+ const idx = content.indexOf(oldText, pos);
118
+ if (idx === -1) break;
119
+ count++;
120
+ pos = idx + oldText.length;
121
+ }
122
+ return count;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Edit application
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function applyFuzzyEdit(
130
+ content: string,
131
+ matchIndex: number,
132
+ matchLength: number,
133
+ oldText: string,
134
+ newText: string,
135
+ ): string {
136
+ const before = content.substring(0, matchIndex);
137
+ const after = content.substring(matchIndex + matchLength);
138
+ const matched = content.substring(matchIndex, matchIndex + matchLength);
139
+
140
+ const matchedLines = matched.split("\n");
141
+ const oldLines = oldText.split("\n");
142
+ const newLines = newText.split("\n");
143
+
144
+ const resultLines: string[] = [];
145
+ for (let i = 0; i < newLines.length; i++) {
146
+ const newLine = newLines[i];
147
+ const oldLine = oldLines[i] ?? "";
148
+ const matchedLine = matchedLines[i] ?? "";
149
+
150
+ const oldTrailing = oldLine.length - oldLine.trimEnd().length;
151
+ const matchedTrailing = matchedLine.length - matchedLine.trimEnd().length;
152
+
153
+ if (matchedTrailing > oldTrailing) {
154
+ const extraStart = matchedLine.trimEnd().length + oldTrailing;
155
+ resultLines.push(newLine + matchedLine.slice(extraStart));
156
+ } else {
157
+ resultLines.push(newLine);
158
+ }
159
+ }
160
+
161
+ return before + resultLines.join("\n") + after;
162
+ }
163
+
164
+ export function applyEdits(
165
+ content: string,
166
+ edits: EditReplacement[],
167
+ filePath: string,
168
+ ): { newContent: string; blocksChanged: number } {
169
+ const normalizedEdits = edits.map((e) => ({
170
+ oldText: normalizeToLF(e.f),
171
+ newText: normalizeToLF(e.r),
172
+ }));
173
+
174
+ // Validate non-empty
175
+ for (let i = 0; i < normalizedEdits.length; i++) {
176
+ if (normalizedEdits[i].oldText.length === 0) {
177
+ throw new Error(`edits[${i}].f (oldText) must not be empty in ${filePath}.`);
178
+ }
179
+ }
180
+
181
+ const baseContent = content;
182
+
183
+ // Match all edits
184
+ interface MatchResult {
185
+ editIndex: number;
186
+ matchIndex: number;
187
+ matchLength: number;
188
+ newText: string;
189
+ oldText: string;
190
+ isExact: boolean;
191
+ }
192
+
193
+ const matchedEdits: MatchResult[] = [];
194
+ for (let i = 0; i < normalizedEdits.length; i++) {
195
+ const edit = normalizedEdits[i];
196
+ const matchResult = fuzzyFindText(baseContent, edit.oldText);
197
+
198
+ if (!matchResult.found) {
199
+ throw new Error(
200
+ edits.length === 1
201
+ ? `Could not find the exact text in ${filePath}. The old text must match exactly including all whitespace and newlines.`
202
+ : `Could not find edits[${i}] in ${filePath}. The f (oldText) must match exactly including all whitespace and newlines.`,
203
+ );
204
+ }
205
+
206
+ const occurrences = matchResult.isExact
207
+ ? countExactOccurrences(baseContent, edit.oldText)
208
+ : countOccurrences(baseContent, edit.oldText);
209
+ if (occurrences > 1) {
210
+ throw new Error(
211
+ edits.length === 1
212
+ ? `Found ${occurrences} occurrences of the text in ${filePath}. The text must be unique. Please provide more context to make it unique.`
213
+ : `Found ${occurrences} occurrences of edits[${i}] in ${filePath}. Each f (oldText) must be unique. Please provide more context to make it unique.`,
214
+ );
215
+ }
216
+
217
+ matchedEdits.push({
218
+ editIndex: i,
219
+ matchIndex: matchResult.index,
220
+ matchLength: matchResult.matchLength,
221
+ newText: edit.newText,
222
+ oldText: edit.oldText,
223
+ isExact: matchResult.isExact,
224
+ });
225
+ }
226
+
227
+ // Sort by position (ascending)
228
+ matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
229
+
230
+ // Check for overlaps
231
+ for (let i = 1; i < matchedEdits.length; i++) {
232
+ const previous = matchedEdits[i - 1];
233
+ const current = matchedEdits[i];
234
+ if (previous.matchIndex + previous.matchLength > current.matchIndex) {
235
+ throw new Error(
236
+ `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${filePath}. Merge them into one edit or target disjoint regions.`,
237
+ );
238
+ }
239
+ }
240
+
241
+ // Apply edits in reverse order to preserve offsets
242
+ let newContent = baseContent;
243
+ for (let i = matchedEdits.length - 1; i >= 0; i--) {
244
+ const edit = matchedEdits[i];
245
+ if (edit.isExact) {
246
+ newContent =
247
+ newContent.substring(0, edit.matchIndex) +
248
+ edit.newText +
249
+ newContent.substring(edit.matchIndex + edit.matchLength);
250
+ } else {
251
+ newContent = applyFuzzyEdit(
252
+ newContent,
253
+ edit.matchIndex,
254
+ edit.matchLength,
255
+ edit.oldText,
256
+ edit.newText,
257
+ );
258
+ }
259
+ }
260
+
261
+ if (baseContent === newContent) {
262
+ throw new Error(
263
+ edits.length === 1
264
+ ? `No changes made to ${filePath}. The replacement produced identical content.`
265
+ : `No changes made to ${filePath}. The replacements produced identical content.`,
266
+ );
267
+ }
268
+
269
+ return {
270
+ newContent,
271
+ blocksChanged: matchedEdits.length,
272
+ };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Path validation
277
+ // ---------------------------------------------------------------------------
278
+
279
+ export function expandTilde(inputPath: string): string {
280
+ if (inputPath === "~") return os.homedir();
281
+ if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
282
+ return inputPath;
283
+ }
284
+
285
+ export function isWithinDirectory(child: string, parent: string): boolean {
286
+ if (process.platform === "win32") {
287
+ const childLower = child.toLowerCase();
288
+ const parentLower = parent.toLowerCase();
289
+ if (childLower === parentLower) return true;
290
+ const sep = path.win32.sep;
291
+ const prefix = parentLower.endsWith(sep) ? parentLower : parentLower + sep;
292
+ return childLower.startsWith(prefix);
293
+ }
294
+ if (child === parent) return true;
295
+ if (parent === "/") return child.startsWith("/");
296
+ return child.startsWith(parent + path.sep);
297
+ }
298
+
299
+ export async function validatePath(inputPath: string, cwd: string): Promise<string> {
300
+ const expandedPath = expandTilde(inputPath);
301
+ return path.resolve(cwd, expandedPath);
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Levenshtein distance (used by execute for suggestions)
306
+ // ---------------------------------------------------------------------------
307
+
308
+ export function levenshtein(a: string, b: string): number {
309
+ const m = a.length;
310
+ const n = b.length;
311
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
312
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
313
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
314
+ for (let i = 1; i <= m; i++) {
315
+ for (let j = 1; j <= n; j++) {
316
+ dp[i][j] =
317
+ a[i - 1] === b[j - 1]
318
+ ? dp[i - 1][j - 1]
319
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
320
+ }
321
+ }
322
+ return dp[m][n];
323
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * batch — tool definition factory.
3
+ *
4
+ * Creates the `batch` and `batch_read` tool instances with schema, argument
5
+ * preparation, execution, and rendering wired up.
6
+ */
7
+
8
+ import { Type } from "@sinclair/typebox";
9
+ import type { BatchTheme, FileOpInput } from "./constants.js";
10
+ import { SAFE_FULL_READ_LIMIT, TARGETED_READ_LINE_LIMIT } from "./constants.js";
11
+ import { executeOperations, suggestSimilarFiles } from "./execute.js";
12
+ import { expandTilde, isWithinDirectory } from "./fuzzy-edit.js";
13
+ import {
14
+ renderBatchCall,
15
+ renderBatchReadCall,
16
+ renderBatchResult,
17
+ } from "./render.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Schema
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const EditOp = Type.Object({
24
+ f: Type.String({
25
+ description:
26
+ "Exact text to find (oldText). Must be unique in the file. All edits matched against original file, not incrementally.",
27
+ }),
28
+ r: Type.String({ description: "Replacement text (newText)." }),
29
+ });
30
+
31
+ const FileOp = Type.Object({
32
+ o: Type.Union([
33
+ Type.Literal("read"),
34
+ Type.Literal("write"),
35
+ Type.Literal("edit"),
36
+ Type.Literal("delete"),
37
+ ]),
38
+ p: Type.String({ description: "Path to the file (relative or absolute)" }),
39
+ c: Type.Optional(
40
+ Type.String({
41
+ description:
42
+ "Full file content. Creates if new, overwrites if exists. Auto-creates parent dirs. Used with o: 'write'.",
43
+ }),
44
+ ),
45
+ e: Type.Optional(
46
+ Type.Array(EditOp, {
47
+ description:
48
+ "One or more targeted replacements matched against the original file, not incrementally.",
49
+ }),
50
+ ),
51
+ s: Type.Optional(
52
+ Type.Number({
53
+ minimum: 1,
54
+ description:
55
+ "1-indexed line number to start reading from (offset). Used with o: 'read'.",
56
+ }),
57
+ ),
58
+ l: Type.Optional(
59
+ Type.Number({
60
+ minimum: 1,
61
+ description:
62
+ "Maximum number of lines to read (limit). Used with o: 'read'.",
63
+ }),
64
+ ),
65
+ });
66
+
67
+ export const WeavePatchParams = Type.Object({
68
+ o: Type.Array(FileOp, {
69
+ description:
70
+ "Ordered list of file operations. Executed sequentially. On failure, remaining operations are skipped.",
71
+ }),
72
+ });
73
+
74
+ export const BatchReadParams = Type.Object({
75
+ o: Type.Array(
76
+ Type.Object({
77
+ o: Type.Literal("read"),
78
+ p: Type.String({ description: "Path to the file (relative or absolute)" }),
79
+ s: Type.Optional(
80
+ Type.Number({
81
+ minimum: 1,
82
+ description:
83
+ "1-indexed line number to start reading from (offset). Used with o: 'read'.",
84
+ }),
85
+ ),
86
+ l: Type.Optional(
87
+ Type.Number({
88
+ minimum: 1,
89
+ description:
90
+ "Maximum number of lines to read (limit). Used with o: 'read'.",
91
+ }),
92
+ ),
93
+ }),
94
+ {
95
+ description:
96
+ "Ordered list of read operations. Executed sequentially. On failure, remaining operations are skipped.",
97
+ },
98
+ ),
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Argument preparation
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function normalizeOp(raw: Record<string, unknown>): Record<string, unknown> {
106
+ const op: Record<string, unknown> = {};
107
+
108
+ // Map operation type
109
+ op.o = raw.o ?? raw.op ?? (raw.c != null || raw.content != null ? "write" : (raw.e != null || raw.edits != null ? "edit" : "read"));
110
+
111
+ // Map path
112
+ op.p = raw.p ?? raw.path;
113
+
114
+ // Map content
115
+ if (raw.c !== undefined) op.c = raw.c;
116
+ else if (raw.content !== undefined) op.c = raw.content;
117
+
118
+ // Map edits
119
+ let editsRaw = raw.e ?? raw.edits;
120
+ if (typeof editsRaw === "string") {
121
+ try { editsRaw = JSON.parse(editsRaw); } catch { /* ignore */ }
122
+ }
123
+ if (Array.isArray(editsRaw)) {
124
+ op.e = editsRaw.map((e: unknown) => {
125
+ if (!e || typeof e !== "object") return e;
126
+ const edit = e as Record<string, unknown>;
127
+ return { f: edit.f ?? edit.oldText, r: edit.r ?? edit.newText };
128
+ });
129
+ }
130
+
131
+ // Map offset / limit
132
+ if (raw.s !== undefined) op.s = raw.s;
133
+ else if (raw.offset !== undefined) op.s = raw.offset;
134
+ if (raw.l !== undefined) op.l = raw.l;
135
+ else if (raw.limit !== undefined) op.l = raw.limit;
136
+
137
+ return op;
138
+ }
139
+
140
+ function prepareArguments(input: unknown): { o: unknown[] } | unknown {
141
+ if (!input || typeof input !== "object") return { o: [] };
142
+
143
+ const args = input as Record<string, unknown>;
144
+
145
+ // Handle legacy top-level format: { path, oldText, newText }
146
+ if (
147
+ typeof args.oldText === "string" &&
148
+ typeof args.newText === "string" &&
149
+ typeof args.path === "string"
150
+ ) {
151
+ return {
152
+ o: [
153
+ normalizeOp({
154
+ o: "edit",
155
+ p: args.path,
156
+ e: [{ oldText: args.oldText, newText: args.newText }],
157
+ }),
158
+ ],
159
+ };
160
+ }
161
+
162
+ // Extract ops array — canonical { o: [...] }, legacy { op: [...] }, legacy { operations: [...] }, or bare array
163
+ let opsArray: unknown[];
164
+ if (Array.isArray(args.o)) {
165
+ opsArray = args.o;
166
+ } else if (Array.isArray(args.op)) {
167
+ opsArray = args.op;
168
+ } else if (Array.isArray(args.operations)) {
169
+ opsArray = args.operations;
170
+ } else if (Array.isArray(args)) {
171
+ opsArray = args;
172
+ } else if (typeof args.p === "string" || typeof args.path === "string") {
173
+ // Single-operation shorthand: { p: "...", o: "read" }
174
+ opsArray = [args];
175
+ } else {
176
+ return { o: [] };
177
+ }
178
+
179
+ // Normalize each operation to single-letter form
180
+ return {
181
+ o: opsArray.map((op: unknown) => {
182
+ if (!op || typeof op !== "object") return op;
183
+ return normalizeOp(op as Record<string, unknown>);
184
+ }),
185
+ };
186
+ }
187
+
188
+ function prepareBatchReadArguments(input: unknown): { o: FileOpInput[] } | unknown {
189
+ const prepared = prepareArguments(input);
190
+ const ops = Array.isArray(prepared) ? prepared : (prepared as { o: unknown[] }).o;
191
+ if (!Array.isArray(ops)) return { o: [] };
192
+
193
+ for (const op of ops) {
194
+ if (!op || typeof op !== "object") continue;
195
+ const obj = op as Record<string, unknown>;
196
+ const opType = String(obj.o ?? obj.op ?? "").toLowerCase();
197
+ if (opType && opType !== "read") {
198
+ throw new Error(`batch_read only supports read operations. Received: ${opType}`);
199
+ }
200
+ }
201
+ return prepared;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Tool factories
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export function createBatchReadTool() {
209
+ return {
210
+ name: "batch_read",
211
+ label: "batch_read",
212
+ description: [
213
+ "Batch read-only file operations — run multiple read ops in a single call.",
214
+ "Each operation is independent and executes sequentially in array order; on failure, remaining operations are skipped.",
215
+ `Full-file reads up to ${SAFE_FULL_READ_LIMIT} lines return raw content; larger full-file reads return a context map for code/infra files or total lines for plain text.`,
216
+ `Targeted reads with l over ${TARGETED_READ_LINE_LIMIT} are clamped with a continuation warning; a single line over the byte limit still errors.`,
217
+ "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
218
+ "Best for reading multiple files or sections in one call.",
219
+ ].join("\n"),
220
+ promptSnippet: "Batch read-only file operations — run multiple read ops in one call",
221
+ promptGuidelines: [
222
+ "Use batch_read to perform multiple file reads in a single call rather than separate tool calls.",
223
+ "Prefer batch_read when reading 2+ files or multiple sections of the same file.",
224
+ `Small full-file reads (<=${SAFE_FULL_READ_LIMIT} lines) return raw content; larger full-file reads return navigable context maps or line counts.`,
225
+ `Use targeted reads with s/l around context-map entries; targeted reads are capped at ${TARGETED_READ_LINE_LIMIT} lines.`,
226
+ "Do not retry the same full-file read when a context map is returned.",
227
+ ],
228
+ parameters: BatchReadParams,
229
+ prepareArguments: prepareBatchReadArguments,
230
+
231
+ async execute(
232
+ _toolCallId: string,
233
+ input: unknown,
234
+ signal: AbortSignal | undefined,
235
+ _onUpdate: unknown,
236
+ ctx: { cwd: string },
237
+ ) {
238
+ let prepared: unknown;
239
+ try {
240
+ prepared = prepareBatchReadArguments(input);
241
+ } catch (err) {
242
+ const message = err instanceof Error ? err.message : String(err);
243
+ return {
244
+ content: [{ type: "text", text: `Error: ${message}` }],
245
+ isError: true,
246
+ };
247
+ }
248
+
249
+ const ops = Array.isArray(prepared)
250
+ ? (prepared as FileOpInput[])
251
+ : (prepared as { o: FileOpInput[] }).o;
252
+
253
+ if (!Array.isArray(ops) || ops.length === 0) {
254
+ return {
255
+ content: [
256
+ { type: "text", text: "Error: o array is required and must not be empty." },
257
+ ],
258
+ isError: true,
259
+ };
260
+ }
261
+
262
+ // Defensive validation: reject any non-read operations
263
+ for (const op of ops) {
264
+ if (op.o !== "read") {
265
+ return {
266
+ content: [
267
+ {
268
+ type: "text",
269
+ text: `Error: batch_read only supports read operations. Received ${op.o} for ${op.p}.`,
270
+ },
271
+ ],
272
+ isError: true,
273
+ };
274
+ }
275
+ }
276
+
277
+ if (signal?.aborted) {
278
+ return {
279
+ content: [{ type: "text", text: "Operation aborted." }],
280
+ isError: true,
281
+ };
282
+ }
283
+
284
+ const { contentText, results } = await executeOperations(ops, ctx.cwd, signal, {
285
+ readOptions: { truncate: false, toolName: "batch_read" },
286
+ includeLimitWarnings: false,
287
+ });
288
+
289
+ return {
290
+ content: [{ type: "text", text: contentText }],
291
+ details: { results },
292
+ };
293
+ },
294
+
295
+ renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchReadCall(args, theme),
296
+ renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
297
+ renderBatchResult(result, expanded, theme, args),
298
+ };
299
+ }
300
+
301
+ export function createBatchTool() {
302
+ return {
303
+ name: "batch",
304
+ label: "batch",
305
+ description: [
306
+ "Batch file operations — run multiple read, write, edit, or delete ops in a single call.",
307
+ "Each operation is independent: edits are matched against the current on-disk file, not against prior operations in the same call.",
308
+ "Operations execute sequentially in array order; on failure, remaining operations are skipped.",
309
+ "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
310
+ "Best for cross-cutting changes, multi-file refactors, or mixing reads with writes across several files.",
311
+ ].join("\n"),
312
+ promptSnippet: "Batch file operations — run multiple read/write/edit/delete ops in one call",
313
+ promptGuidelines: [
314
+ "Use batch to perform multiple file operations in a single call rather than separate tool calls.",
315
+ "Prefer batch when touching 2+ files or mixing creates, edits, reads, and deletes.",
316
+ "Each operation is independent — edits match the on-disk file, not prior ops in the same call.",
317
+ "For single-file edits, the edit tool is fine; batch shines for cross-cutting and multi-file work.",
318
+ ],
319
+ parameters: WeavePatchParams,
320
+ prepareArguments: prepareArguments,
321
+
322
+ async execute(
323
+ _toolCallId: string,
324
+ input: unknown,
325
+ signal: AbortSignal | undefined,
326
+ _onUpdate: unknown,
327
+ ctx: { cwd: string },
328
+ ) {
329
+ const prepared = prepareArguments(input);
330
+ // prepareArguments always returns { o: [...] }, but handle
331
+ // legacy bare arrays for backward compatibility
332
+ const ops = Array.isArray(prepared)
333
+ ? prepared as FileOpInput[]
334
+ : (prepared as { o: FileOpInput[] }).o;
335
+
336
+ if (!Array.isArray(ops) || ops.length === 0) {
337
+ return {
338
+ content: [
339
+ { type: "text", text: "Error: o array is required and must not be empty." },
340
+ ],
341
+ isError: true,
342
+ };
343
+ }
344
+
345
+ if (signal?.aborted) {
346
+ return {
347
+ content: [{ type: "text", text: "Operation aborted." }],
348
+ isError: true,
349
+ };
350
+ }
351
+
352
+ const { contentText, results } = await executeOperations(ops, ctx.cwd, signal);
353
+
354
+ return {
355
+ content: [{ type: "text", text: contentText }],
356
+ details: { results },
357
+ };
358
+ },
359
+
360
+ renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchCall(args, theme),
361
+ renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
362
+ renderBatchResult(result, expanded, theme, args),
363
+ };
364
+ }