pi-agent-flow 1.2.3 → 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.
package/src/batch.ts CHANGED
@@ -1,1649 +1,15 @@
1
1
  /**
2
- * batch — Unified batch file operations tool.
2
+ * batch — re-export barrel.
3
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
- import { Text, TruncatedText } from "@mariozechner/pi-tui";
13
-
14
- // ---------------------------------------------------------------------------
15
- // Schema
16
- // ---------------------------------------------------------------------------
17
-
18
- const EditOp = Type.Object({
19
- f: Type.String({
20
- description:
21
- "Exact text to find (oldText). Must be unique in the file. All edits matched against original file, not incrementally.",
22
- }),
23
- r: Type.String({ description: "Replacement text (newText)." }),
24
- });
25
-
26
- const FileOp = Type.Object({
27
- o: Type.Union([
28
- Type.Literal("read"),
29
- Type.Literal("write"),
30
- Type.Literal("edit"),
31
- Type.Literal("delete"),
32
- ]),
33
- p: Type.String({ description: "Path to the file (relative or absolute)" }),
34
- c: Type.Optional(
35
- Type.String({
36
- description:
37
- "Full file content. Creates if new, overwrites if exists. Auto-creates parent dirs. Used with o: 'write'.",
38
- }),
39
- ),
40
- e: Type.Optional(
41
- Type.Array(EditOp, {
42
- description:
43
- "One or more targeted replacements matched against the original file, not incrementally.",
44
- }),
45
- ),
46
- s: Type.Optional(
47
- Type.Number({
48
- minimum: 1,
49
- description:
50
- "1-indexed line number to start reading from (offset). Used with o: 'read'.",
51
- }),
52
- ),
53
- l: Type.Optional(
54
- Type.Number({
55
- minimum: 1,
56
- description:
57
- "Maximum number of lines to read (limit). Used with o: 'read'.",
58
- }),
59
- ),
60
- });
61
-
62
- export const WeavePatchParams = Type.Object({
63
- o: Type.Array(FileOp, {
64
- description:
65
- "Ordered list of file operations. Executed sequentially. On failure, remaining operations are skipped.",
66
- }),
67
- });
68
-
69
- // ---------------------------------------------------------------------------
70
- // Types
71
- // ---------------------------------------------------------------------------
72
-
73
- interface EditReplacement {
74
- f: string;
75
- r: string;
76
- }
77
-
78
- interface FileOpInput {
79
- o: "read" | "write" | "edit" | "delete";
80
- p: string;
81
- c?: string;
82
- e?: EditReplacement[];
83
- s?: number;
84
- l?: number;
85
- }
86
-
87
- interface ContextMapEntry {
88
- kind: string;
89
- name: string;
90
- startLine: number;
91
- endLine: number;
92
- parent?: string;
93
- }
94
-
95
- interface OpResult {
96
- op: "read" | "write" | "edit" | "delete";
97
- path: string;
98
- status: "ok" | "error" | "skipped";
99
- content?: string;
100
- bytes?: number;
101
- blocksChanged?: number;
102
- totalLines?: number;
103
- contextMap?: boolean;
104
- language?: string;
105
- symbols?: ContextMapEntry[];
106
- symbolsTruncated?: boolean;
107
- warning?: string;
108
- truncated?: boolean;
109
- nextOffset?: number;
110
- error?: string;
111
- hint?: string;
112
- }
113
-
114
- interface ReadTruncationResult {
115
- content: string;
116
- truncated: boolean;
117
- nextOffset?: number;
118
- linesRead: number;
119
- }
120
-
121
- interface ReadOptions {
122
- /**
123
- * When false, readWithOffsetLimit ignores regular batch MAX_LINES and total
124
- * MAX_BYTES caps. batch_read applies its own safe full-file and targeted-read
125
- * guards before calling this helper.
126
- */
127
- truncate?: boolean;
128
- toolName?: "batch" | "batch_read";
129
- }
130
-
131
- interface ExecuteOptions {
132
- readOptions?: ReadOptions;
133
- includeLimitWarnings?: boolean;
134
- }
135
-
136
- // ---------------------------------------------------------------------------
137
- // Helpers
138
- // ---------------------------------------------------------------------------
139
-
140
- const MAX_LINES = 2000;
141
- const MAX_BYTES = 50 * 1024; // 50KB
142
- const SAFE_FULL_READ_LIMIT = 300;
143
- const TARGETED_READ_LINE_LIMIT = 1000;
144
- const MAX_CONTEXT_MAP_ENTRIES = 100;
145
-
146
- function normalizeToLF(text: string): string {
147
- return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
148
- }
149
-
150
- function restoreLineEndings(text: string, ending: string): string {
151
- return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
152
- }
153
-
154
- function detectLineEnding(content: string): string {
155
- const crlfIdx = content.indexOf("\r\n");
156
- const lfIdx = content.indexOf("\n");
157
- if (lfIdx === -1) return "\n";
158
- if (crlfIdx === -1) return "\n";
159
- return crlfIdx < lfIdx ? "\r\n" : "\n";
160
- }
161
-
162
- function stripBom(content: string): { bom: string; text: string } {
163
- return content.startsWith("\uFEFF")
164
- ? { bom: "\uFEFF", text: content.slice(1) }
165
- : { bom: "", text: content };
166
- }
167
-
168
- type ContextLanguage =
169
- | "typescript"
170
- | "javascript"
171
- | "python"
172
- | "terraform"
173
- | "hcl"
174
- | "yaml"
175
- | "dockerfile"
176
- | "plain";
177
-
178
- interface FileContextMap {
179
- language: ContextLanguage;
180
- symbols: ContextMapEntry[];
181
- symbolsTruncated?: boolean;
182
- }
183
-
184
- function isBatchRead(options: ExecuteOptions): boolean {
185
- return options.readOptions?.toolName === "batch_read";
186
- }
187
-
188
- function isFullFileRead(op: FileOpInput, totalLines: number): boolean {
189
- const start = op.s ?? 1;
190
- if (start !== 1) return false;
191
- return op.l === undefined || op.l >= totalLines;
192
- }
193
-
194
- function buildBatchReadSafetyWarning(): string {
195
- return `[batch_read safety] Raw content truncated at ${TARGETED_READ_LINE_LIMIT} lines to preserve context. Adjust your 's' and 'l' parameters to read further.`;
196
- }
197
-
198
- function detectContextLanguage(filePath: string): ContextLanguage {
199
- const base = path.basename(filePath);
200
- const lowerBase = base.toLowerCase();
201
- const ext = path.extname(lowerBase);
202
-
203
- if (
204
- lowerBase === "dockerfile" ||
205
- lowerBase.startsWith("dockerfile.") ||
206
- lowerBase === ".dockerfile" ||
207
- lowerBase.endsWith(".dockerfile")
208
- ) {
209
- return "dockerfile";
210
- }
211
-
212
- if ([".ts", ".tsx", ".mts", ".cts"].includes(ext)) return "typescript";
213
- if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) return "javascript";
214
- if ([".py", ".pyw"].includes(ext)) return "python";
215
- if ([".tf", ".tfvars"].includes(ext)) return "terraform";
216
- if (ext === ".hcl") return "hcl";
217
- if ([".yml", ".yaml"].includes(ext)) return "yaml";
218
- return "plain";
219
- }
220
-
221
- function buildFileContextMap(filePath: string, lines: string[]): FileContextMap {
222
- const language = detectContextLanguage(filePath);
223
- let symbols: ContextMapEntry[] = [];
224
-
225
- try {
226
- switch (language) {
227
- case "typescript":
228
- case "javascript":
229
- symbols = extractTsJsSymbols(lines);
230
- break;
231
- case "python":
232
- symbols = extractPythonSymbols(lines);
233
- break;
234
- case "terraform":
235
- case "hcl":
236
- symbols = extractTerraformSymbols(lines);
237
- break;
238
- case "yaml":
239
- symbols = extractYamlSymbols(lines);
240
- break;
241
- case "dockerfile":
242
- symbols = extractDockerfileSymbols(lines);
243
- break;
244
- default:
245
- symbols = [];
246
- }
247
- } catch {
248
- symbols = [];
249
- }
250
-
251
- const sorted = symbols
252
- .filter((entry) => entry.startLine > 0 && entry.endLine >= entry.startLine)
253
- .sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
254
-
255
- return {
256
- language,
257
- symbols: sorted.slice(0, MAX_CONTEXT_MAP_ENTRIES),
258
- symbolsTruncated: sorted.length > MAX_CONTEXT_MAP_ENTRIES || undefined,
259
- };
260
- }
261
-
262
- function countLeadingWhitespace(line: string): number {
263
- return line.match(/^\s*/)?.[0].length ?? 0;
264
- }
265
-
266
- function findBraceBlockEnd(lines: string[], startIndex: number): number {
267
- let depth = 0;
268
- let sawOpenBrace = false;
269
-
270
- for (let i = startIndex; i < lines.length; i++) {
271
- for (const char of lines[i]) {
272
- if (char === "{") {
273
- depth++;
274
- sawOpenBrace = true;
275
- } else if (char === "}") {
276
- depth--;
277
- }
278
- }
279
- if (sawOpenBrace && depth <= 0) return i + 1;
280
- }
281
-
282
- return sawOpenBrace ? lines.length : startIndex + 1;
283
- }
284
-
285
- function findStatementEnd(lines: string[], startIndex: number): number {
286
- for (let i = startIndex; i < lines.length; i++) {
287
- if (lines[i].includes(";")) return i + 1;
288
- }
289
- return startIndex + 1;
290
- }
291
-
292
- function findIndentedBlockEnd(lines: string[], startIndex: number, indent: number): number {
293
- for (let i = startIndex + 1; i < lines.length; i++) {
294
- const trimmed = lines[i].trim();
295
- if (!trimmed || trimmed.startsWith("#")) continue;
296
- if (countLeadingWhitespace(lines[i]) <= indent) return i;
297
- }
298
- return lines.length;
299
- }
300
-
301
- function findYamlBlockEnd(lines: string[], startIndex: number, indent: number): number {
302
- for (let i = startIndex + 1; i < lines.length; i++) {
303
- const trimmed = lines[i].trim();
304
- if (!trimmed || trimmed.startsWith("#")) continue;
305
- if (trimmed === "---" || countLeadingWhitespace(lines[i]) <= indent) return i;
306
- }
307
- return lines.length;
308
- }
309
-
310
- function extractTsJsSymbols(lines: string[]): ContextMapEntry[] {
311
- const entries: ContextMapEntry[] = [];
312
- const classes: ContextMapEntry[] = [];
313
-
314
- for (let i = 0; i < lines.length; i++) {
315
- const line = lines[i];
316
- let match = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/);
317
- if (match) {
318
- entries.push({ kind: "function", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
319
- continue;
320
- }
321
-
322
- match = line.match(/^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)\b/);
323
- if (match) {
324
- const entry = { kind: "class", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) };
325
- entries.push(entry);
326
- classes.push(entry);
327
- continue;
328
- }
329
-
330
- match = line.match(/^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)\b/);
331
- if (match) {
332
- entries.push({ kind: "interface", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
333
- continue;
334
- }
335
-
336
- match = line.match(/^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\b/);
337
- if (match) {
338
- entries.push({ kind: "type", name: match[1], startLine: i + 1, endLine: line.includes("{") ? findBraceBlockEnd(lines, i) : findStatementEnd(lines, i) });
339
- continue;
340
- }
341
-
342
- match = line.match(/^\s*(?:export\s+)?enum\s+([A-Za-z_$][\w$]*)\b/);
343
- if (match) {
344
- entries.push({ kind: "enum", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
345
- continue;
346
- }
347
-
348
- match = line.match(/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/);
349
- if (match) {
350
- entries.push({ kind: "function", name: match[1], startLine: i + 1, endLine: line.includes("{") ? findBraceBlockEnd(lines, i) : findStatementEnd(lines, i) });
351
- }
352
- }
353
-
354
- const methodNameBlacklist = new Set(["if", "for", "while", "switch", "catch", "function"]);
355
- for (const classEntry of classes) {
356
- for (let i = classEntry.startLine; i < classEntry.endLine - 1 && i < lines.length; i++) {
357
- const match = lines[i].match(/^\s*(?:(?:public|private|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]+>)?\s*\([^)]*\)\s*(?::\s*[^={]+)?\s*\{/);
358
- if (!match || methodNameBlacklist.has(match[1])) continue;
359
- entries.push({
360
- kind: "method",
361
- name: `${classEntry.name}.${match[1]}`,
362
- parent: classEntry.name,
363
- startLine: i + 1,
364
- endLine: findBraceBlockEnd(lines, i),
365
- });
366
- }
367
- }
368
-
369
- return entries;
370
- }
371
-
372
- function extractPythonSymbols(lines: string[]): ContextMapEntry[] {
373
- const entries: ContextMapEntry[] = [];
374
- const classes: Array<ContextMapEntry & { indent: number }> = [];
375
-
376
- for (let i = 0; i < lines.length; i++) {
377
- const match = lines[i].match(/^(\s*)class\s+([A-Za-z_]\w*)\b/);
378
- if (!match) continue;
379
- const indent = match[1].length;
380
- const entry = { kind: "class", name: match[2], startLine: i + 1, endLine: findIndentedBlockEnd(lines, i, indent), indent };
381
- classes.push(entry);
382
- entries.push(entry);
383
- }
384
-
385
- for (let i = 0; i < lines.length; i++) {
386
- const match = lines[i].match(/^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
387
- if (!match) continue;
388
- const indent = match[1].length;
389
- const parent = classes.find((cls) => i + 1 > cls.startLine && i + 1 < cls.endLine && indent > cls.indent);
390
- entries.push({
391
- kind: parent ? "method" : "function",
392
- name: parent ? `${parent.name}.${match[2]}` : match[2],
393
- parent: parent?.name,
394
- startLine: i + 1,
395
- endLine: findIndentedBlockEnd(lines, i, indent),
396
- });
397
- }
398
-
399
- return entries;
400
- }
401
-
402
- function extractTerraformSymbols(lines: string[]): ContextMapEntry[] {
403
- const entries: ContextMapEntry[] = [];
404
-
405
- for (let i = 0; i < lines.length; i++) {
406
- const line = lines[i];
407
- let match = line.match(/^\s*(resource|data)\s+"([^"]+)"\s+"([^"]+)"\s*\{/);
408
- if (match) {
409
- entries.push({ kind: match[1], name: `${match[2]}.${match[3]}`, startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
410
- continue;
411
- }
412
-
413
- match = line.match(/^\s*(module|variable|output|provider)\s+"([^"]+)"\s*\{/);
414
- if (match) {
415
- entries.push({ kind: match[1], name: match[2], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
416
- continue;
417
- }
418
-
419
- match = line.match(/^\s*(locals|terraform)\s*\{/);
420
- if (match) {
421
- entries.push({ kind: match[1], name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
422
- continue;
423
- }
424
-
425
- match = line.match(/^([A-Za-z_][\w-]*)\s*=/);
426
- if (match) {
427
- entries.push({ kind: "assignment", name: match[1], startLine: i + 1, endLine: i + 1 });
428
- }
429
- }
430
-
431
- return entries;
432
- }
433
-
434
- function extractYamlSymbols(lines: string[]): ContextMapEntry[] {
435
- const entries: ContextMapEntry[] = [];
436
-
437
- let docStart = 0;
438
- for (let i = 0; i <= lines.length; i++) {
439
- if (i < lines.length && lines[i].trim() !== "---") continue;
440
- const docEnd = i === lines.length ? lines.length : i;
441
- const docLines = lines.slice(docStart, docEnd);
442
- const kindLine = docLines.findIndex((line) => /^kind:\s*\S+/.test(line));
443
- const nameLine = docLines.findIndex((line) => /^\s{2}name:\s*\S+/.test(line));
444
- if (kindLine >= 0 && nameLine >= 0) {
445
- const kind = docLines[kindLine].replace(/^kind:\s*/, "").trim();
446
- const name = docLines[nameLine].replace(/^\s{2}name:\s*/, "").trim();
447
- entries.push({ kind, name, startLine: docStart + 1, endLine: Math.max(docStart + 1, docEnd) });
448
- }
449
- docStart = i + 1;
450
- }
451
-
452
- for (let i = 0; i < lines.length; i++) {
453
- const topLevel = lines[i].match(/^([A-Za-z_][\w.-]*):\s*/);
454
- if (topLevel) {
455
- const key = topLevel[1];
456
- entries.push({ kind: "section", name: key, startLine: i + 1, endLine: findYamlBlockEnd(lines, i, 0) });
457
-
458
- if (["jobs", "services", "volumes", "networks"].includes(key)) {
459
- for (let j = i + 1; j < lines.length; j++) {
460
- const trimmed = lines[j].trim();
461
- if (!trimmed || trimmed.startsWith("#")) continue;
462
- const indent = countLeadingWhitespace(lines[j]);
463
- if (indent <= 0) break;
464
- const child = lines[j].match(/^\s{2}([A-Za-z0-9_.-]+):\s*/);
465
- if (!child) continue;
466
- const childKind = key === "jobs" ? "job" : key.slice(0, -1);
467
- entries.push({ kind: childKind, name: child[1], startLine: j + 1, endLine: findYamlBlockEnd(lines, j, 2) });
468
- }
469
- }
470
- }
471
-
472
- const step = lines[i].match(/^\s*-\s+(?:name:\s*(.+)|uses:\s*(.+)|run:\s*(.+))/);
473
- if (step) {
474
- const name = (step[1] ?? step[2] ?? step[3] ?? "step").trim();
475
- entries.push({ kind: "step", name: name.slice(0, 120), startLine: i + 1, endLine: i + 1 });
476
- }
477
- }
478
-
479
- return entries;
480
- }
481
-
482
- function findDockerInstructionEnd(lines: string[], startIndex: number): number {
483
- let endIndex = startIndex;
484
- while (endIndex + 1 < lines.length && lines[endIndex].trimEnd().endsWith("\\")) {
485
- endIndex++;
486
- }
487
- return endIndex + 1;
488
- }
489
-
490
- function extractDockerfileSymbols(lines: string[]): ContextMapEntry[] {
491
- const entries: ContextMapEntry[] = [];
492
- const fromLines: number[] = [];
493
-
494
- for (let i = 0; i < lines.length; i++) {
495
- if (/^\s*FROM\s+/i.test(lines[i])) fromLines.push(i);
496
- }
497
-
498
- for (let idx = 0; idx < fromLines.length; idx++) {
499
- const i = fromLines[idx];
500
- const nextFrom = fromLines[idx + 1] ?? lines.length;
501
- const match = lines[i].match(/^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
502
- const image = match?.[1] ?? "unknown";
503
- const alias = match?.[2];
504
- entries.push({ kind: "stage", name: alias ? `${alias} FROM ${image}` : image, startLine: i + 1, endLine: nextFrom });
505
- }
506
-
507
- const important = /^(RUN|COPY|ADD|ENTRYPOINT|CMD|EXPOSE|ENV|ARG|WORKDIR)\b\s*(.*)/i;
508
- for (let i = 0; i < lines.length; i++) {
509
- const match = lines[i].trim().match(important);
510
- if (!match) continue;
511
- entries.push({ kind: match[1].toUpperCase(), name: (match[2] || match[1]).slice(0, 120), startLine: i + 1, endLine: findDockerInstructionEnd(lines, i) });
512
- }
513
-
514
- return entries;
515
- }
516
-
517
- function readWithOffsetLimit(
518
- content: string,
519
- offset?: number,
520
- limit?: number,
521
- filePath?: string,
522
- options: ReadOptions = {},
523
- ): ReadTruncationResult {
524
- const allLines = content.split("\n");
525
- const totalFileLines = allLines.length;
526
- const shouldTruncate = options.truncate !== false;
527
- const toolName = options.toolName ?? "batch";
528
-
529
- // Validate offset
530
- if (offset !== undefined && offset > totalFileLines) {
531
- throw new Error(
532
- `Offset ${offset} is beyond end of file (${totalFileLines} lines total)`,
533
- );
534
- }
535
-
536
- // Determine the start line (convert 1-indexed to 0-indexed)
537
- const startLine = offset !== undefined ? Math.max(0, offset - 1) : 0;
538
-
539
- // Determine end line
540
- let endLine = totalFileLines;
541
- if (limit !== undefined) {
542
- endLine = Math.min(startLine + limit, totalFileLines);
543
- }
544
-
545
- let selectedLines = allLines.slice(startLine, endLine);
546
- let truncated = false;
547
- let nextOffset: number | undefined;
548
-
549
- // Apply max-lines cap for regular batch reads. batch_read clamps oversized
550
- // targeted reads before this helper and context-maps large full-file reads.
551
- if (shouldTruncate && selectedLines.length > MAX_LINES) {
552
- selectedLines = selectedLines.slice(0, MAX_LINES);
553
- truncated = true;
554
- }
555
-
556
- // A single selected line that exceeds the byte cap is not safely splittable by
557
- // line-oriented offsets, so keep the existing hard error in both modes.
558
- for (let i = 0; i < selectedLines.length; i++) {
559
- if (Buffer.byteLength(selectedLines[i], "utf-8") > MAX_BYTES) {
560
- const lineDisplay = startLine + i + 1;
561
- throw new Error(
562
- `Line ${lineDisplay} exceeds limit. Try: ${toolName} with o:"read", s:${lineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
563
- );
564
- }
565
- }
566
-
567
- // Join and check byte size
568
- let result = selectedLines.join("\n");
569
-
570
- // Truncate by total bytes for regular batch reads only. batch_read relies on
571
- // its line-oriented safety guards and still rejects an individual huge line.
572
- if (shouldTruncate && Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
573
- let byteAccum = 0;
574
- let keepLines = 0;
575
- for (let i = 0; i < selectedLines.length; i++) {
576
- byteAccum += Buffer.byteLength(selectedLines[i], "utf-8") + (i > 0 ? 1 : 0); // newline separator between lines
577
- if (byteAccum > MAX_BYTES) break;
578
- keepLines = i + 1;
579
- }
580
- selectedLines = selectedLines.slice(0, keepLines);
581
- result = selectedLines.join("\n");
582
- truncated = true;
583
- }
584
-
585
- // Calculate nextOffset for continuation
586
- const lastLineRead = startLine + selectedLines.length;
587
- if (truncated || (limit !== undefined && lastLineRead < totalFileLines)) {
588
- nextOffset = lastLineRead + 1; // 1-indexed
589
- }
590
-
591
- // Append truncation/continuation hints
592
- if (truncated) {
593
- const endDisplay = startLine + selectedLines.length;
594
- const startDisplay = startLine + 1;
595
- result += `\n\n[Showing lines ${startDisplay}-${endDisplay} of ${totalFileLines}. Use s=${nextOffset} to continue.]`;
596
- } else if (limit !== undefined && lastLineRead < totalFileLines) {
597
- const remaining = totalFileLines - lastLineRead;
598
- result += `\n\n[${remaining} more lines in file. Use s=${nextOffset} to continue.]`;
599
- }
600
-
601
- return { content: result, truncated, nextOffset, linesRead: selectedLines.length };
602
- }
603
-
604
- function levenshtein(a: string, b: string): number {
605
- const m = a.length;
606
- const n = b.length;
607
- const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
608
- for (let i = 0; i <= m; i++) dp[i][0] = i;
609
- for (let j = 0; j <= n; j++) dp[0][j] = j;
610
- for (let i = 1; i <= m; i++) {
611
- for (let j = 1; j <= n; j++) {
612
- dp[i][j] =
613
- a[i - 1] === b[j - 1]
614
- ? dp[i - 1][j - 1]
615
- : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
616
- }
617
- }
618
- return dp[m][n];
619
- }
620
-
621
- /**
622
- * Scan the parent directory (or cwd) for files with similar names.
623
- * Returns up to 3 suggestions sorted by similarity.
624
- */
625
- export async function suggestSimilarFiles(
626
- inputPath: string,
627
- cwd: string,
628
- ): Promise<string[]> {
629
- const resolved = path.resolve(cwd, inputPath);
630
- const dir = path.dirname(resolved);
631
- const target = path.basename(resolved);
632
-
633
- try {
634
- const entries = await fs.readdir(dir, { withFileTypes: true });
635
- const candidates: { name: string; dist: number }[] = [];
636
-
637
- for (const entry of entries) {
638
- const name = entry.name;
639
- // Skip hidden files and node_modules
640
- if (name.startsWith(".") || name === "node_modules") continue;
641
-
642
- const dist = levenshtein(target.toLowerCase(), name.toLowerCase());
643
- const maxLen = Math.max(target.length, name.length);
644
- // Only suggest if reasonably similar (within 40% edit distance, or shares prefix)
645
- if (dist <= Math.ceil(maxLen * 0.4) || name.startsWith(target.slice(0, 3))) {
646
- candidates.push({ name: entry.isDirectory() ? name + "/" : name, dist });
647
- }
648
- }
649
-
650
- return candidates
651
- .sort((a, b) => a.dist - b.dist)
652
- .slice(0, 3)
653
- .map((c) => path.join(path.relative(cwd, dir), c.name));
654
- } catch {
655
- return [];
656
- }
657
- }
658
-
659
- function getErrorHint(error: string): string {
660
- if (error.includes("File not found") || error.includes("file not found"))
661
- return "Verify the path exists.";
662
- if (error.includes("Could not find"))
663
- return "Re-read the file first, then retry with exact f (oldText).";
664
- if (error.includes("occurrences"))
665
- return "Add more surrounding context to make oldText unique.";
666
- if (error.includes("overlap"))
667
- return "Merge overlapping edits into one.";
668
- if (error.includes("No changes"))
669
- return "File already has this content. No edit needed.";
670
- if (error.includes("is not readable") || error.includes("not readable"))
671
- return "Check file permissions.";
672
- if (error.includes("ENOENT") || error.includes("no such file"))
673
- return "Verify the path exists.";
674
- if (error.includes("is beyond end of file"))
675
- return "Use a smaller offset within the file length.";
676
- return "";
677
- }
678
-
679
- // ---------------------------------------------------------------------------
680
- // Fuzzy matching (simplified)
681
- // ---------------------------------------------------------------------------
682
-
683
- function normalizeForMatch(text: string): string {
684
- return text
685
- .split("\n")
686
- .map((line) => line.trimEnd())
687
- .join("\n");
688
- }
689
-
690
- function buildPositionMap(original: string): number[] {
691
- const normalized = normalizeForMatch(original);
692
- const map: number[] = new Array(normalized.length + 1);
693
- let oi = 0;
694
- let ni = 0;
695
-
696
- while (oi < original.length && ni < normalized.length) {
697
- map[ni] = oi;
698
- if (original[oi] === normalized[ni]) {
699
- oi++;
700
- ni++;
701
- } else {
702
- oi++;
703
- }
704
- }
705
-
706
- while (ni < normalized.length) {
707
- map[ni] = oi;
708
- ni++;
709
- }
710
-
711
- map[normalized.length] = original.length;
712
- return map;
713
- }
714
-
715
- function fuzzyFindText(
716
- content: string,
717
- oldText: string,
718
- ): { found: boolean; index: number; matchLength: number; isExact: boolean } {
719
- // Try exact match first
720
- const exactIndex = content.indexOf(oldText);
721
- if (exactIndex !== -1) {
722
- return { found: true, index: exactIndex, matchLength: oldText.length, isExact: true };
723
- }
724
-
725
- // Try trimmed match, returning original indices
726
- const normalizedContent = normalizeForMatch(content);
727
- const normalizedOld = normalizeForMatch(oldText);
728
- const fuzzyIndex = normalizedContent.indexOf(normalizedOld);
729
- if (fuzzyIndex !== -1) {
730
- const map = buildPositionMap(content);
731
- const originalStart = map[fuzzyIndex];
732
- const originalEnd = map[fuzzyIndex + normalizedOld.length];
733
- return { found: true, index: originalStart, matchLength: originalEnd - originalStart, isExact: false };
734
- }
735
-
736
- return { found: false, index: -1, matchLength: 0, isExact: false };
737
- }
738
-
739
- function countOccurrences(content: string, oldText: string): number {
740
- const normalizedContent = normalizeForMatch(content);
741
- const normalizedOld = normalizeForMatch(oldText);
742
- let count = 0;
743
- let pos = 0;
744
- while (true) {
745
- const idx = normalizedContent.indexOf(normalizedOld, pos);
746
- if (idx === -1) break;
747
- count++;
748
- pos = idx + normalizedOld.length;
749
- }
750
- return count;
751
- }
752
-
753
- function countExactOccurrences(content: string, oldText: string): number {
754
- let count = 0;
755
- let pos = 0;
756
- while (true) {
757
- const idx = content.indexOf(oldText, pos);
758
- if (idx === -1) break;
759
- count++;
760
- pos = idx + oldText.length;
761
- }
762
- return count;
763
- }
764
-
765
- // ---------------------------------------------------------------------------
766
- // Edit logic
767
- // ---------------------------------------------------------------------------
768
-
769
- /**
770
- * Apply a fuzzy edit, preserving trailing whitespace from the original matched
771
- * text that wasn't explicitly present in the oldText.
772
- *
773
- * This prevents normalizeForMatch from stripping trailing whitespace on lines
774
- * that are being edited when fuzzy matching is used.
775
- */
776
- function applyFuzzyEdit(
777
- content: string,
778
- matchIndex: number,
779
- matchLength: number,
780
- oldText: string,
781
- newText: string,
782
- ): string {
783
- const before = content.substring(0, matchIndex);
784
- const after = content.substring(matchIndex + matchLength);
785
- const matched = content.substring(matchIndex, matchIndex + matchLength);
786
-
787
- const matchedLines = matched.split("\n");
788
- const oldLines = oldText.split("\n");
789
- const newLines = newText.split("\n");
790
-
791
- const resultLines: string[] = [];
792
- for (let i = 0; i < newLines.length; i++) {
793
- const newLine = newLines[i];
794
- const oldLine = oldLines[i] ?? "";
795
- const matchedLine = matchedLines[i] ?? "";
796
-
797
- const oldTrailing = oldLine.length - oldLine.trimEnd().length;
798
- const matchedTrailing = matchedLine.length - matchedLine.trimEnd().length;
799
-
800
- if (matchedTrailing > oldTrailing) {
801
- const extraStart = matchedLine.trimEnd().length + oldTrailing;
802
- resultLines.push(newLine + matchedLine.slice(extraStart));
803
- } else {
804
- resultLines.push(newLine);
805
- }
806
- }
807
-
808
- return before + resultLines.join("\n") + after;
809
- }
810
-
811
- function applyEdits(
812
- content: string,
813
- edits: EditReplacement[],
814
- filePath: string,
815
- ): { newContent: string; blocksChanged: number } {
816
- const normalizedEdits = edits.map((e) => ({
817
- oldText: normalizeToLF(e.f),
818
- newText: normalizeToLF(e.r),
819
- }));
820
-
821
- // Validate non-empty
822
- for (let i = 0; i < normalizedEdits.length; i++) {
823
- if (normalizedEdits[i].oldText.length === 0) {
824
- throw new Error(`edits[${i}].f (oldText) must not be empty in ${filePath}.`);
825
- }
826
- }
827
-
828
- const baseContent = content;
829
-
830
- // Match all edits
831
- interface MatchResult {
832
- editIndex: number;
833
- matchIndex: number;
834
- matchLength: number;
835
- newText: string;
836
- oldText: string;
837
- isExact: boolean;
838
- }
839
-
840
- const matchedEdits: MatchResult[] = [];
841
- for (let i = 0; i < normalizedEdits.length; i++) {
842
- const edit = normalizedEdits[i];
843
- const matchResult = fuzzyFindText(baseContent, edit.oldText);
844
-
845
- if (!matchResult.found) {
846
- throw new Error(
847
- edits.length === 1
848
- ? `Could not find the exact text in ${filePath}. The old text must match exactly including all whitespace and newlines.`
849
- : `Could not find edits[${i}] in ${filePath}. The f (oldText) must match exactly including all whitespace and newlines.`,
850
- );
851
- }
852
-
853
- const occurrences = matchResult.isExact
854
- ? countExactOccurrences(baseContent, edit.oldText)
855
- : countOccurrences(baseContent, edit.oldText);
856
- if (occurrences > 1) {
857
- throw new Error(
858
- edits.length === 1
859
- ? `Found ${occurrences} occurrences of the text in ${filePath}. The text must be unique. Please provide more context to make it unique.`
860
- : `Found ${occurrences} occurrences of edits[${i}] in ${filePath}. Each f (oldText) must be unique. Please provide more context to make it unique.`,
861
- );
862
- }
863
-
864
- matchedEdits.push({
865
- editIndex: i,
866
- matchIndex: matchResult.index,
867
- matchLength: matchResult.matchLength,
868
- newText: edit.newText,
869
- oldText: edit.oldText,
870
- isExact: matchResult.isExact,
871
- });
872
- }
873
-
874
- // Sort by position (ascending)
875
- matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
876
-
877
- // Check for overlaps
878
- for (let i = 1; i < matchedEdits.length; i++) {
879
- const previous = matchedEdits[i - 1];
880
- const current = matchedEdits[i];
881
- if (previous.matchIndex + previous.matchLength > current.matchIndex) {
882
- throw new Error(
883
- `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${filePath}. Merge them into one edit or target disjoint regions.`,
884
- );
885
- }
886
- }
887
-
888
- // Apply edits in reverse order to preserve offsets
889
- let newContent = baseContent;
890
- for (let i = matchedEdits.length - 1; i >= 0; i--) {
891
- const edit = matchedEdits[i];
892
- if (edit.isExact) {
893
- newContent =
894
- newContent.substring(0, edit.matchIndex) +
895
- edit.newText +
896
- newContent.substring(edit.matchIndex + edit.matchLength);
897
- } else {
898
- newContent = applyFuzzyEdit(
899
- newContent,
900
- edit.matchIndex,
901
- edit.matchLength,
902
- edit.oldText,
903
- edit.newText,
904
- );
905
- }
906
- }
907
-
908
- if (baseContent === newContent) {
909
- throw new Error(
910
- edits.length === 1
911
- ? `No changes made to ${filePath}. The replacement produced identical content.`
912
- : `No changes made to ${filePath}. The replacements produced identical content.`,
913
- );
914
- }
915
-
916
- return {
917
- newContent,
918
- blocksChanged: matchedEdits.length,
919
- };
920
- }
921
-
922
- // ---------------------------------------------------------------------------
923
- // Path validation
924
- // ---------------------------------------------------------------------------
925
-
926
- function expandTilde(inputPath: string): string {
927
- if (inputPath === "~") return os.homedir();
928
- if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
929
- return inputPath;
930
- }
931
-
932
- export function isWithinDirectory(child: string, parent: string): boolean {
933
- if (process.platform === "win32") {
934
- const childLower = child.toLowerCase();
935
- const parentLower = parent.toLowerCase();
936
- if (childLower === parentLower) return true;
937
- const sep = path.win32.sep;
938
- const prefix = parentLower.endsWith(sep) ? parentLower : parentLower + sep;
939
- return childLower.startsWith(prefix);
940
- }
941
- if (child === parent) return true;
942
- if (parent === "/") return child.startsWith("/");
943
- return child.startsWith(parent + path.sep);
944
- }
945
-
946
- async function validatePath(inputPath: string, cwd: string): Promise<string> {
947
- const expandedPath = expandTilde(inputPath);
948
- return path.resolve(cwd, expandedPath);
949
- }
950
-
951
- // ---------------------------------------------------------------------------
952
- // prepareArguments shim
953
- // ---------------------------------------------------------------------------
954
-
955
- /**
956
- * Normalize a single operation object from any legacy format to the new
957
- * single-letter canonical form: { o, p, c, e, s, l }.
958
- */
959
- function normalizeOp(raw: Record<string, unknown>): Record<string, unknown> {
960
- const op: Record<string, unknown> = {};
961
-
962
- // Map operation type
963
- op.o = raw.o ?? raw.op ?? (raw.c != null || raw.content != null ? "write" : (raw.e != null || raw.edits != null ? "edit" : "read"));
964
-
965
- // Map path
966
- op.p = raw.p ?? raw.path;
967
-
968
- // Map content
969
- if (raw.c !== undefined) op.c = raw.c;
970
- else if (raw.content !== undefined) op.c = raw.content;
971
-
972
- // Map edits
973
- let editsRaw = raw.e ?? raw.edits;
974
- if (typeof editsRaw === "string") {
975
- try { editsRaw = JSON.parse(editsRaw); } catch { /* ignore */ }
976
- }
977
- if (Array.isArray(editsRaw)) {
978
- op.e = editsRaw.map((e: unknown) => {
979
- if (!e || typeof e !== "object") return e;
980
- const edit = e as Record<string, unknown>;
981
- return { f: edit.f ?? edit.oldText, r: edit.r ?? edit.newText };
982
- });
983
- }
984
-
985
- // Map offset / limit
986
- if (raw.s !== undefined) op.s = raw.s;
987
- else if (raw.offset !== undefined) op.s = raw.offset;
988
- if (raw.l !== undefined) op.l = raw.l;
989
- else if (raw.limit !== undefined) op.l = raw.limit;
990
-
991
- return op;
992
- }
993
-
994
- /**
995
- * Normalize input arguments to the canonical { o: [...] } shape.
996
- * Handles legacy formats, bare arrays, and single-operation shorthands.
997
- * Always returns { o: FileOpInput[] } to match WeavePatchParams schema.
998
- */
999
- function prepareArguments(input: unknown): { o: unknown[] } | unknown {
1000
- if (!input || typeof input !== "object") return { o: [] };
1001
-
1002
- const args = input as Record<string, unknown>;
1003
-
1004
- // Handle legacy top-level format: { path, oldText, newText }
1005
- if (
1006
- typeof args.oldText === "string" &&
1007
- typeof args.newText === "string" &&
1008
- typeof args.path === "string"
1009
- ) {
1010
- return {
1011
- o: [
1012
- normalizeOp({
1013
- o: "edit",
1014
- p: args.path,
1015
- e: [{ oldText: args.oldText, newText: args.newText }],
1016
- }),
1017
- ],
1018
- };
1019
- }
1020
-
1021
- // Extract ops array — canonical { o: [...] }, legacy { op: [...] }, legacy { operations: [...] }, or bare array
1022
- let opsArray: unknown[];
1023
- if (Array.isArray(args.o)) {
1024
- opsArray = args.o;
1025
- } else if (Array.isArray(args.op)) {
1026
- opsArray = args.op;
1027
- } else if (Array.isArray(args.operations)) {
1028
- opsArray = args.operations;
1029
- } else if (Array.isArray(args)) {
1030
- opsArray = args;
1031
- } else if (typeof args.p === "string" || typeof args.path === "string") {
1032
- // Single-operation shorthand: { p: "...", o: "read" }
1033
- opsArray = [args];
1034
- } else {
1035
- return { o: [] };
1036
- }
1037
-
1038
- // Normalize each operation to single-letter form
1039
- return {
1040
- o: opsArray.map((op: unknown) => {
1041
- if (!op || typeof op !== "object") return op;
1042
- return normalizeOp(op as Record<string, unknown>);
1043
- }),
1044
- };
1045
- }
1046
-
1047
- // ---------------------------------------------------------------------------
1048
- // Main execute function
1049
- // ---------------------------------------------------------------------------
1050
-
1051
- async function executeOperations(
1052
- operations: FileOpInput[],
1053
- cwd: string,
1054
- signal?: AbortSignal,
1055
- options: ExecuteOptions = {},
1056
- ): Promise<{ summary: string; contentText: string; results: OpResult[] }> {
1057
- const results: OpResult[] = [];
1058
- let failed = false;
1059
-
1060
- const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
1061
- const errors: { path: string; op: string; message: string; hint?: string }[] = [];
1062
- const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
1063
- const includeLimitWarnings = options.includeLimitWarnings ?? true;
1064
-
1065
- for (const op of operations) {
1066
- if (signal?.aborted) {
1067
- results.push({ op: op.o, path: op.p, status: "skipped", error: "Operation aborted." });
1068
- counts.skipped++;
1069
- continue;
1070
- }
1071
-
1072
- if (failed) {
1073
- results.push({ op: op.o, path: op.p, status: "skipped" });
1074
- counts.skipped++;
1075
- continue;
1076
- }
1077
-
1078
- try {
1079
- const resolvedPath = await validatePath(op.p, cwd);
1080
-
1081
- switch (op.o) {
1082
- case "read": {
1083
- // Access check before reading
1084
- try {
1085
- await fs.access(resolvedPath);
1086
- } catch {
1087
- throw new Error(`File not found: ${op.p}`);
1088
- }
1089
- try {
1090
- await fs.access(resolvedPath, fs.constants.R_OK);
1091
- } catch {
1092
- throw new Error(`File not readable: ${op.p}`);
1093
- }
1094
-
1095
- const rawContent = await fs.readFile(resolvedPath, "utf-8");
1096
- const { text } = stripBom(rawContent);
1097
- const allLines = text.split("\n");
1098
- const totalFileLines = allLines.length;
1099
-
1100
- if (isBatchRead(options) && isFullFileRead(op, totalFileLines) && totalFileLines > SAFE_FULL_READ_LIMIT) {
1101
- const context = buildFileContextMap(op.p, allLines);
1102
- results.push({
1103
- op: "read",
1104
- path: op.p,
1105
- status: "ok",
1106
- totalLines: totalFileLines,
1107
- contextMap: true,
1108
- language: context.language !== "plain" ? context.language : undefined,
1109
- symbols: context.symbols.length > 0 ? context.symbols : undefined,
1110
- symbolsTruncated: context.symbolsTruncated,
1111
- });
1112
- counts.read++;
1113
- break;
1114
- }
1115
-
1116
- let effectiveLimit = op.l;
1117
- let safetyTruncated = false;
1118
- let safetyWarning: string | undefined;
1119
- if (isBatchRead(options) && !isFullFileRead(op, totalFileLines)) {
1120
- if (effectiveLimit === undefined || effectiveLimit > TARGETED_READ_LINE_LIMIT) {
1121
- effectiveLimit = TARGETED_READ_LINE_LIMIT;
1122
- safetyTruncated = true;
1123
- safetyWarning = buildBatchReadSafetyWarning();
1124
- }
1125
- }
1126
-
1127
- const { content: readContent, truncated, nextOffset, linesRead } =
1128
- readWithOffsetLimit(text, op.s, effectiveLimit, op.p, options.readOptions);
1129
- const finalContent = safetyWarning
1130
- ? `${readContent}\n\n${safetyWarning}`
1131
- : readContent;
1132
- const finalTruncated = truncated || safetyTruncated;
1133
-
1134
- if (finalTruncated || (includeLimitWarnings && effectiveLimit !== undefined && (op.s ?? 1) - 1 + effectiveLimit < totalFileLines)) {
1135
- const shownLines = finalTruncated ? linesRead : effectiveLimit!;
1136
- truncatedFiles.push({
1137
- path: op.p,
1138
- shown: shownLines,
1139
- total: totalFileLines,
1140
- nextOffset,
1141
- });
1142
- }
1143
-
1144
- results.push({
1145
- op: "read",
1146
- path: op.p,
1147
- status: "ok",
1148
- content: finalContent,
1149
- totalLines: totalFileLines,
1150
- warning: safetyWarning,
1151
- truncated: finalTruncated || undefined,
1152
- nextOffset,
1153
- });
1154
- counts.read++;
1155
- break;
1156
- }
1157
-
1158
- case "write": {
1159
- if (!op.c && op.c !== "") {
1160
- throw new Error("c (content) is required for write operations.");
1161
- }
1162
- await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
1163
- await fs.writeFile(resolvedPath, op.c!, "utf-8");
1164
- results.push({
1165
- op: "write",
1166
- path: op.p,
1167
- status: "ok",
1168
- bytes: Buffer.byteLength(op.c!, "utf-8"),
1169
- });
1170
- counts.write++;
1171
- break;
1172
- }
1173
-
1174
- case "edit": {
1175
- if (!op.e || op.e.length === 0) {
1176
- throw new Error("e (edits) array is required for edit operations.");
1177
- }
1178
-
1179
- const rawContent = await fs.readFile(resolvedPath, "utf-8");
1180
- const { bom, text: contentWithoutBom } = stripBom(rawContent);
1181
- const originalEnding = detectLineEnding(contentWithoutBom);
1182
- const normalizedContent = normalizeToLF(contentWithoutBom);
1183
-
1184
- const { newContent, blocksChanged } = applyEdits(
1185
- normalizedContent,
1186
- op.e,
1187
- op.p,
1188
- );
1189
-
1190
- const finalContent = bom + restoreLineEndings(newContent, originalEnding);
1191
- await fs.writeFile(resolvedPath, finalContent, "utf-8");
1192
-
1193
- results.push({
1194
- op: "edit",
1195
- path: op.p,
1196
- status: "ok",
1197
- blocksChanged,
1198
- });
1199
- counts.edit++;
1200
- break;
1201
- }
1202
-
1203
- case "delete": {
1204
- let stat;
1205
- try {
1206
- stat = await fs.lstat(resolvedPath);
1207
- } catch (err: any) {
1208
- if (err.code === "ENOENT") {
1209
- throw new Error(`File not found: ${op.p}`);
1210
- }
1211
- throw err;
1212
- }
1213
- if (stat.isDirectory()) {
1214
- throw new Error(`Cannot delete directory: ${op.p}. Use a recursive removal tool or delete files individually.`);
1215
- }
1216
- await fs.unlink(resolvedPath);
1217
- results.push({ op: "delete", path: op.p, status: "ok" });
1218
- counts.delete++;
1219
- break;
1220
- }
1221
-
1222
- default:
1223
- throw new Error(`Unknown operation type: ${op.o}`);
1224
- }
1225
- } catch (err) {
1226
- failed = true;
1227
- counts.error++;
1228
- const message = err instanceof Error ? err.message : String(err);
1229
-
1230
- // Enrich file-not-found errors with fuzzy filename suggestions
1231
- let hint = getErrorHint(message);
1232
- if (
1233
- message.includes("File not found") ||
1234
- message.includes("file not found") ||
1235
- message.includes("ENOENT") ||
1236
- message.includes("no such file")
1237
- ) {
1238
- const suggestions = await suggestSimilarFiles(op.p, cwd);
1239
- if (suggestions.length > 0) {
1240
- hint += ` Did you mean: ${suggestions.join(", ")}?`;
1241
- }
1242
- }
1243
-
1244
- errors.push({ path: op.p, op: op.o, message, hint });
1245
- results.push({
1246
- op: op.o,
1247
- path: op.p,
1248
- status: "error",
1249
- error: message,
1250
- hint,
1251
- });
1252
- }
1253
-
1254
- }
1255
- // Build the enhanced summary and content text
1256
- const summary = buildSummary(counts, errors, truncatedFiles);
1257
- const contentText = buildContentText(summary, results);
1258
-
1259
- return { summary, contentText, results };
1260
- }
1261
-
1262
- function buildSummary(
1263
- counts: { read: number; write: number; edit: number; delete: number; error: number; skipped: number },
1264
- errors: { path: string; op: string; message: string; hint?: string }[],
1265
- truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[],
1266
- ): string {
1267
- const totalSuccess =
1268
- counts.read + counts.write + counts.edit + counts.delete;
1269
- const totalOps = totalSuccess + counts.error + counts.skipped;
1270
-
1271
- const parts: string[] = [];
1272
-
1273
- // Build the success breakdown
1274
- const successParts: string[] = [];
1275
- if (counts.read > 0)
1276
- successParts.push(
1277
- `${counts.read} read${counts.read > 1 ? "s" : ""}`,
1278
- );
1279
- if (counts.write > 0)
1280
- successParts.push(
1281
- `${counts.write} write${counts.write > 1 ? "s" : ""}`,
1282
- );
1283
- if (counts.edit > 0)
1284
- successParts.push(
1285
- `${counts.edit} edit${counts.edit > 1 ? "s" : ""}`,
1286
- );
1287
- if (counts.delete > 0)
1288
- successParts.push(
1289
- `${counts.delete} delete${counts.delete > 1 ? "s" : ""}`,
1290
- );
1291
-
1292
- if (counts.error === 0) {
1293
- // All success
1294
- parts.push(`✓ ${totalOps} operations: ${successParts.join(", ")}`);
1295
- } else {
1296
- // Mixed success/failure
1297
- parts.push(
1298
- `✗ ${counts.error} failed${counts.skipped > 0 ? `, ${counts.skipped} skipped` : ""}`,
1299
- );
1300
- if (totalSuccess > 0) {
1301
- parts.push(` ✓ ${successParts.join(", ")} ok`);
1302
- }
1303
- for (const err of errors) {
1304
- const hint = err.hint ?? "";
1305
- const hintSuffix = hint ? ` — ${hint}` : "";
1306
- parts.push(` ✗ ${err.op} ${err.path}: ${err.message}${hintSuffix}`);
1307
- }
1308
- }
1309
-
1310
- // Truncation warnings
1311
- for (const tf of truncatedFiles) {
1312
- if (tf.nextOffset) {
1313
- parts.push(
1314
- ` ⚠ ${tf.path} truncated (${tf.shown}/${tf.total} lines) — use s=${tf.nextOffset}`,
1315
- );
1316
- }
1317
- }
1318
-
1319
- return parts.join("\n");
1320
- }
1321
-
1322
- function shortenPath(p: string): string {
1323
- const home = os.homedir();
1324
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
1325
- }
1326
-
1327
- type BatchTheme = {
1328
- fg: (color: string, text: string) => string;
1329
- bold: (s: string) => string;
1330
- bg: (color: string, text: string) => string;
1331
- };
1332
-
1333
- function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: string; e?: unknown[] }> {
1334
- let rawOps: unknown[];
1335
- if (Array.isArray(args.o)) rawOps = args.o;
1336
- else if (Array.isArray(args.op)) rawOps = args.op;
1337
- else if (Array.isArray(args.operations)) rawOps = args.operations;
1338
- else if (Array.isArray(args)) rawOps = args;
1339
- else rawOps = [];
1340
-
1341
- return rawOps
1342
- .filter((op): op is Record<string, unknown> => !!op && typeof op === "object")
1343
- .map((op) => {
1344
- const opName = String(op.o ?? op.op ?? "?");
1345
- const opPath = String(op.p ?? op.path ?? "?");
1346
- const edits = Array.isArray(op.e) ? op.e : Array.isArray(op.edits) ? op.edits : undefined;
1347
- return { o: opName, p: opPath, e: edits };
1348
- });
1349
- }
1350
-
1351
- function formatBatchCall(args: Record<string, unknown>): string {
1352
- const ops = extractBatchOps(args);
1353
- if (ops.length === 0) return "batch (empty)";
1354
-
1355
- const parts: string[] = [];
1356
- for (const op of ops) {
1357
- const shortPath = shortenPath(op.p);
1358
- if (op.o === "edit" && op.e && op.e.length > 1) {
1359
- parts.push(`edit ${shortPath} (${op.e.length} blocks)`);
1360
- } else {
1361
- parts.push(`${op.o} ${shortPath}`);
1362
- }
1363
- }
1364
-
1365
- if (parts.length <= 3) {
1366
- return parts.join(", ");
1367
- }
1368
- return `${parts.slice(0, 2).join(", ")} +${parts.length - 2} more`;
1369
- }
1370
-
1371
- function renderBatchCall(args: Record<string, unknown>, theme: BatchTheme): Text {
1372
- const summary = formatBatchCall(args);
1373
- return new Text(theme.fg("muted", "batch ") + theme.fg("accent", summary), 0, 0);
1374
- }
1375
-
1376
- function renderBatchResult(
1377
- result: { content?: Array<{ type: string; text?: string }> },
1378
- expanded: boolean,
1379
- _theme: BatchTheme,
1380
- _args?: Record<string, unknown>,
1381
- ): Text | TruncatedText {
1382
- const fullText = result.content?.find((c) => c.type === "text")?.text ?? "";
1383
- if (!expanded) {
1384
- const summary = fullText.split("\n")[0] ?? "";
1385
- return new TruncatedText(summary, 0, 0);
1386
- }
1387
- return new Text(fullText, 0, 0);
1388
- }
1389
-
1390
- function buildContextMapText(result: OpResult): string {
1391
- const title = result.language || result.symbols ? "context map" : "file summary";
1392
- const lines: string[] = [`\n--- ${result.path} ${title} ---`];
1393
- lines.push(`Total lines: ${result.totalLines ?? 0}`);
1394
- if (result.language) lines.push(`Language: ${result.language}`);
1395
- lines.push("");
1396
- lines.push(`Full-file content omitted because file exceeds SAFE_FULL_READ_LIMIT=${SAFE_FULL_READ_LIMIT} lines.`);
1397
- lines.push("Use targeted reads with s/l, for example:");
1398
- lines.push(`{ "o": "read", "p": "${result.path}", "s": <startLine>, "l": <lineCount> }`);
1399
-
1400
- if (result.symbols && result.symbols.length > 0) {
1401
- lines.push("");
1402
- lines.push("Context map:");
1403
- for (const entry of result.symbols) {
1404
- lines.push(`- ${entry.kind} ${entry.name} ${entry.startLine}-${entry.endLine}`);
1405
- }
1406
- if (result.symbolsTruncated) {
1407
- lines.push(`... [Context map truncated. Over ${MAX_CONTEXT_MAP_ENTRIES} entries detected. Use targeted reads to explore further.]`);
1408
- }
1409
- } else if (result.language) {
1410
- lines.push("");
1411
- lines.push("No context map entries detected for this structured file.");
1412
- }
1413
-
1414
- return lines.join("\n");
1415
- }
1416
-
1417
- function buildContentText(summary: string, results: OpResult[]): string {
1418
- const sections: string[] = [summary];
1419
-
1420
- for (const r of results) {
1421
- if (r.op === "read" && r.status === "ok" && r.contextMap) {
1422
- sections.push(buildContextMapText(r));
1423
- } else if (r.op === "read" && r.status === "ok" && r.content) {
1424
- const lineInfo = r.totalLines !== undefined ? ` (${r.totalLines} lines)` : "";
1425
- sections.push(`\n--- ${r.path}${lineInfo} ---\n${r.content}`);
1426
- } else if (r.op === "edit" && r.status === "ok") {
1427
- const blockInfo = r.blocksChanged !== undefined ? `${r.blocksChanged} block${r.blocksChanged > 1 ? "s" : ""}` : "";
1428
- sections.push(`\n--- edit: ${r.path} (${blockInfo}) ---`);
1429
- } else if (r.op === "write" && r.status === "ok") {
1430
- sections.push(`\n--- write: ${r.path} (${r.bytes ?? 0} bytes) ---`);
1431
- } else if (r.op === "delete" && r.status === "ok") {
1432
- sections.push(`\n--- delete: ${r.path} ---`);
1433
- } else if (r.status === "error") {
1434
- sections.push(`\n--- ${r.op}: ${r.path} ---\nError: ${r.error}`);
1435
- }
1436
- }
1437
-
1438
- return sections.join("");
1439
- }
1440
-
1441
- // ---------------------------------------------------------------------------
1442
- // Tool definition factory
1443
- // ---------------------------------------------------------------------------
1444
-
1445
- export const BatchReadParams = Type.Object({
1446
- o: Type.Array(
1447
- Type.Object({
1448
- o: Type.Literal("read"),
1449
- p: Type.String({ description: "Path to the file (relative or absolute)" }),
1450
- s: Type.Optional(
1451
- Type.Number({
1452
- minimum: 1,
1453
- description:
1454
- "1-indexed line number to start reading from (offset). Used with o: 'read'.",
1455
- }),
1456
- ),
1457
- l: Type.Optional(
1458
- Type.Number({
1459
- minimum: 1,
1460
- description:
1461
- "Maximum number of lines to read (limit). Used with o: 'read'.",
1462
- }),
1463
- ),
1464
- }),
1465
- {
1466
- description:
1467
- "Ordered list of read operations. Executed sequentially. On failure, remaining operations are skipped.",
1468
- },
1469
- ),
1470
- });
1471
-
1472
- function prepareBatchReadArguments(input: unknown): { o: FileOpInput[] } | unknown {
1473
- const prepared = prepareArguments(input);
1474
- const ops = Array.isArray(prepared) ? prepared : (prepared as { o: unknown[] }).o;
1475
- if (!Array.isArray(ops)) return { o: [] };
1476
-
1477
- for (const op of ops) {
1478
- if (!op || typeof op !== "object") continue;
1479
- const obj = op as Record<string, unknown>;
1480
- const opType = String(obj.o ?? obj.op ?? "").toLowerCase();
1481
- if (opType && opType !== "read") {
1482
- throw new Error(`batch_read only supports read operations. Received: ${opType}`);
1483
- }
1484
- }
1485
- return prepared;
1486
- }
1487
-
1488
- function renderBatchReadCall(args: Record<string, unknown>, theme: BatchTheme): Text {
1489
- const summary = formatBatchCall(args);
1490
- return new Text(theme.fg("muted", "batch_read ") + theme.fg("accent", summary), 0, 0);
1491
- }
1492
-
1493
- export function createBatchReadTool() {
1494
- return {
1495
- name: "batch_read",
1496
- label: "batch_read",
1497
- description: [
1498
- "Batch read-only file operations — run multiple read ops in a single call.",
1499
- "Each operation is independent and executes sequentially in array order; on failure, remaining operations are skipped.",
1500
- `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.`,
1501
- `Targeted reads with l over ${TARGETED_READ_LINE_LIMIT} are clamped with a continuation warning; a single line over the byte limit still errors.`,
1502
- "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
1503
- "Best for reading multiple files or sections in one call.",
1504
- ].join("\n"),
1505
- promptSnippet: "Batch read-only file operations — run multiple read ops in one call",
1506
- promptGuidelines: [
1507
- "Use batch_read to perform multiple file reads in a single call rather than separate tool calls.",
1508
- "Prefer batch_read when reading 2+ files or multiple sections of the same file.",
1509
- `Small full-file reads (<=${SAFE_FULL_READ_LIMIT} lines) return raw content; larger full-file reads return navigable context maps or line counts.`,
1510
- `Use targeted reads with s/l around context-map entries; targeted reads are capped at ${TARGETED_READ_LINE_LIMIT} lines.`,
1511
- "Do not retry the same full-file read when a context map is returned.",
1512
- ],
1513
- parameters: BatchReadParams,
1514
- prepareArguments: prepareBatchReadArguments,
1515
-
1516
- async execute(
1517
- _toolCallId: string,
1518
- input: unknown,
1519
- signal: AbortSignal | undefined,
1520
- _onUpdate: unknown,
1521
- ctx: { cwd: string },
1522
- ) {
1523
- let prepared: unknown;
1524
- try {
1525
- prepared = prepareBatchReadArguments(input);
1526
- } catch (err) {
1527
- const message = err instanceof Error ? err.message : String(err);
1528
- return {
1529
- content: [{ type: "text", text: `Error: ${message}` }],
1530
- isError: true,
1531
- };
1532
- }
1533
-
1534
- const ops = Array.isArray(prepared)
1535
- ? (prepared as FileOpInput[])
1536
- : (prepared as { o: FileOpInput[] }).o;
1537
-
1538
- if (!Array.isArray(ops) || ops.length === 0) {
1539
- return {
1540
- content: [
1541
- { type: "text", text: "Error: o array is required and must not be empty." },
1542
- ],
1543
- isError: true,
1544
- };
1545
- }
1546
-
1547
- // Defensive validation: reject any non-read operations
1548
- for (const op of ops) {
1549
- if (op.o !== "read") {
1550
- return {
1551
- content: [
1552
- {
1553
- type: "text",
1554
- text: `Error: batch_read only supports read operations. Received ${op.o} for ${op.p}.`,
1555
- },
1556
- ],
1557
- isError: true,
1558
- };
1559
- }
1560
- }
1561
-
1562
- if (signal?.aborted) {
1563
- return {
1564
- content: [{ type: "text", text: "Operation aborted." }],
1565
- isError: true,
1566
- };
1567
- }
1568
-
1569
- const { contentText, results } = await executeOperations(ops, ctx.cwd, signal, {
1570
- readOptions: { truncate: false, toolName: "batch_read" },
1571
- includeLimitWarnings: false,
1572
- });
1573
-
1574
- return {
1575
- content: [{ type: "text", text: contentText }],
1576
- details: { results },
1577
- };
1578
- },
1579
-
1580
- renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchReadCall(args, theme),
1581
- renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
1582
- renderBatchResult(result, expanded, theme, args),
1583
- };
1584
- }
1585
-
1586
- export function createBatchTool() {
1587
- return {
1588
- name: "batch",
1589
- label: "batch",
1590
- description: [
1591
- "Batch file operations — run multiple read, write, edit, or delete ops in a single call.",
1592
- "Each operation is independent: edits are matched against the current on-disk file, not against prior operations in the same call.",
1593
- "Operations execute sequentially in array order; on failure, remaining operations are skipped.",
1594
- "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
1595
- "Best for cross-cutting changes, multi-file refactors, or mixing reads with writes across several files.",
1596
- ].join("\n"),
1597
- promptSnippet: "Batch file operations — run multiple read/write/edit/delete ops in one call",
1598
- promptGuidelines: [
1599
- "Use batch to perform multiple file operations in a single call rather than separate tool calls.",
1600
- "Prefer batch when touching 2+ files or mixing creates, edits, reads, and deletes.",
1601
- "Each operation is independent — edits match the on-disk file, not prior ops in the same call.",
1602
- "For single-file edits, the edit tool is fine; batch shines for cross-cutting and multi-file work.",
1603
- ],
1604
- parameters: WeavePatchParams,
1605
- prepareArguments: prepareArguments,
1606
-
1607
- async execute(
1608
- _toolCallId: string,
1609
- input: unknown,
1610
- signal: AbortSignal | undefined,
1611
- _onUpdate: unknown,
1612
- ctx: { cwd: string },
1613
- ) {
1614
- const prepared = prepareArguments(input);
1615
- // prepareArguments always returns { o: [...] }, but handle
1616
- // legacy bare arrays for backward compatibility
1617
- const ops = Array.isArray(prepared)
1618
- ? prepared as FileOpInput[]
1619
- : (prepared as { o: FileOpInput[] }).o;
1620
-
1621
- if (!Array.isArray(ops) || ops.length === 0) {
1622
- return {
1623
- content: [
1624
- { type: "text", text: "Error: o array is required and must not be empty." },
1625
- ],
1626
- isError: true,
1627
- };
1628
- }
1629
-
1630
- if (signal?.aborted) {
1631
- return {
1632
- content: [{ type: "text", text: "Operation aborted." }],
1633
- isError: true,
1634
- };
1635
- }
1636
-
1637
- const { contentText, results } = await executeOperations(ops, ctx.cwd, signal);
1638
-
1639
- return {
1640
- content: [{ type: "text", text: contentText }],
1641
- details: { results },
1642
- };
1643
- },
1644
-
1645
- renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchCall(args, theme),
1646
- renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
1647
- renderBatchResult(result, expanded, theme, args),
1648
- };
1649
- }
4
+ * Delegates to src/batch/ submodules. This file exists for backward
5
+ * compatibility with existing import paths.
6
+ */
7
+ export {
8
+ WeavePatchParams,
9
+ BatchReadParams,
10
+ createBatchTool,
11
+ createBatchReadTool,
12
+ } from "./batch/index.js";
13
+
14
+ export { suggestSimilarFiles } from "./batch/execute.js";
15
+ export { isWithinDirectory } from "./batch/fuzzy-edit.js";