pi-agent-flow 1.1.0 → 1.2.2
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 +158 -44
- package/agents/audit.md +19 -24
- package/agents/build.md +28 -49
- package/agents/craft.md +16 -24
- package/agents/debug.md +14 -23
- package/agents/ideas.md +16 -17
- package/agents/scout.md +15 -20
- package/package.json +4 -15
- package/{agents.ts → src/agents.ts} +10 -4
- package/src/ambient.d.ts +85 -0
- package/{batch.ts → src/batch.ts} +687 -121
- package/src/cli-args.ts +283 -0
- package/src/config.ts +310 -0
- package/{flow.ts → src/flow.ts} +93 -21
- package/{hooks.ts → src/hooks.ts} +6 -6
- package/{index.ts → src/index.ts} +522 -103
- package/{render-utils.ts → src/render-utils.ts} +2 -2
- package/{render.ts → src/render.ts} +54 -10
- package/src/runner-events.ts +692 -0
- package/src/structured-output.ts +97 -0
- package/{types.ts → src/types.ts} +100 -0
- package/config.ts +0 -102
- package/runner-cli.js +0 -254
- package/runner-events.js +0 -559
- /package/{web-tool.ts → src/web-tool.ts} +0 -0
|
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { Type } from "@sinclair/typebox";
|
|
12
|
+
import { Text, TruncatedText } from "@mariozechner/pi-tui";
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Schema
|
|
@@ -83,6 +84,14 @@ interface FileOpInput {
|
|
|
83
84
|
l?: number;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
interface ContextMapEntry {
|
|
88
|
+
kind: string;
|
|
89
|
+
name: string;
|
|
90
|
+
startLine: number;
|
|
91
|
+
endLine: number;
|
|
92
|
+
parent?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
interface OpResult {
|
|
87
96
|
op: "read" | "write" | "edit" | "delete";
|
|
88
97
|
path: string;
|
|
@@ -91,6 +100,11 @@ interface OpResult {
|
|
|
91
100
|
bytes?: number;
|
|
92
101
|
blocksChanged?: number;
|
|
93
102
|
totalLines?: number;
|
|
103
|
+
contextMap?: boolean;
|
|
104
|
+
language?: string;
|
|
105
|
+
symbols?: ContextMapEntry[];
|
|
106
|
+
symbolsTruncated?: boolean;
|
|
107
|
+
warning?: string;
|
|
94
108
|
truncated?: boolean;
|
|
95
109
|
nextOffset?: number;
|
|
96
110
|
error?: string;
|
|
@@ -101,6 +115,22 @@ interface ReadTruncationResult {
|
|
|
101
115
|
content: string;
|
|
102
116
|
truncated: boolean;
|
|
103
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;
|
|
104
134
|
}
|
|
105
135
|
|
|
106
136
|
// ---------------------------------------------------------------------------
|
|
@@ -109,6 +139,9 @@ interface ReadTruncationResult {
|
|
|
109
139
|
|
|
110
140
|
const MAX_LINES = 2000;
|
|
111
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;
|
|
112
145
|
|
|
113
146
|
function normalizeToLF(text: string): string {
|
|
114
147
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
@@ -132,14 +165,366 @@ function stripBom(content: string): { bom: string; text: string } {
|
|
|
132
165
|
: { bom: "", text: content };
|
|
133
166
|
}
|
|
134
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
|
+
|
|
135
517
|
function readWithOffsetLimit(
|
|
136
518
|
content: string,
|
|
137
519
|
offset?: number,
|
|
138
520
|
limit?: number,
|
|
139
521
|
filePath?: string,
|
|
522
|
+
options: ReadOptions = {},
|
|
140
523
|
): ReadTruncationResult {
|
|
141
524
|
const allLines = content.split("\n");
|
|
142
525
|
const totalFileLines = allLines.length;
|
|
526
|
+
const shouldTruncate = options.truncate !== false;
|
|
527
|
+
const toolName = options.toolName ?? "batch";
|
|
143
528
|
|
|
144
529
|
// Validate offset
|
|
145
530
|
if (offset !== undefined && offset > totalFileLines) {
|
|
@@ -161,25 +546,30 @@ function readWithOffsetLimit(
|
|
|
161
546
|
let truncated = false;
|
|
162
547
|
let nextOffset: number | undefined;
|
|
163
548
|
|
|
164
|
-
// Apply max-lines cap
|
|
165
|
-
|
|
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) {
|
|
166
552
|
selectedLines = selectedLines.slice(0, MAX_LINES);
|
|
167
553
|
truncated = true;
|
|
168
554
|
}
|
|
169
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
|
+
|
|
170
567
|
// Join and check byte size
|
|
171
568
|
let result = selectedLines.join("\n");
|
|
172
569
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|
|
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) {
|
|
183
573
|
let byteAccum = 0;
|
|
184
574
|
let keepLines = 0;
|
|
185
575
|
for (let i = 0; i < selectedLines.length; i++) {
|
|
@@ -208,7 +598,7 @@ function readWithOffsetLimit(
|
|
|
208
598
|
result += `\n\n[${remaining} more lines in file. Use s=${nextOffset} to continue.]`;
|
|
209
599
|
}
|
|
210
600
|
|
|
211
|
-
return { content: result, truncated, nextOffset };
|
|
601
|
+
return { content: result, truncated, nextOffset, linesRead: selectedLines.length };
|
|
212
602
|
}
|
|
213
603
|
|
|
214
604
|
function levenshtein(a: string, b: string): number {
|
|
@@ -277,8 +667,6 @@ function getErrorHint(error: string): string {
|
|
|
277
667
|
return "Merge overlapping edits into one.";
|
|
278
668
|
if (error.includes("No changes"))
|
|
279
669
|
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
670
|
if (error.includes("is not readable") || error.includes("not readable"))
|
|
283
671
|
return "Check file permissions.";
|
|
284
672
|
if (error.includes("ENOENT") || error.includes("no such file"))
|
|
@@ -557,101 +945,7 @@ export function isWithinDirectory(child: string, parent: string): boolean {
|
|
|
557
945
|
|
|
558
946
|
async function validatePath(inputPath: string, cwd: string): Promise<string> {
|
|
559
947
|
const expandedPath = expandTilde(inputPath);
|
|
560
|
-
|
|
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;
|
|
948
|
+
return path.resolve(cwd, expandedPath);
|
|
655
949
|
}
|
|
656
950
|
|
|
657
951
|
// ---------------------------------------------------------------------------
|
|
@@ -758,6 +1052,7 @@ async function executeOperations(
|
|
|
758
1052
|
operations: FileOpInput[],
|
|
759
1053
|
cwd: string,
|
|
760
1054
|
signal?: AbortSignal,
|
|
1055
|
+
options: ExecuteOptions = {},
|
|
761
1056
|
): Promise<{ summary: string; contentText: string; results: OpResult[] }> {
|
|
762
1057
|
const results: OpResult[] = [];
|
|
763
1058
|
let failed = false;
|
|
@@ -765,6 +1060,7 @@ async function executeOperations(
|
|
|
765
1060
|
const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
|
|
766
1061
|
const errors: { path: string; op: string; message: string; hint?: string }[] = [];
|
|
767
1062
|
const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
|
|
1063
|
+
const includeLimitWarnings = options.includeLimitWarnings ?? true;
|
|
768
1064
|
|
|
769
1065
|
for (const op of operations) {
|
|
770
1066
|
if (signal?.aborted) {
|
|
@@ -801,15 +1097,42 @@ async function executeOperations(
|
|
|
801
1097
|
const allLines = text.split("\n");
|
|
802
1098
|
const totalFileLines = allLines.length;
|
|
803
1099
|
|
|
804
|
-
|
|
805
|
-
|
|
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
|
+
}
|
|
806
1115
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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!;
|
|
813
1136
|
truncatedFiles.push({
|
|
814
1137
|
path: op.p,
|
|
815
1138
|
shown: shownLines,
|
|
@@ -822,9 +1145,10 @@ async function executeOperations(
|
|
|
822
1145
|
op: "read",
|
|
823
1146
|
path: op.p,
|
|
824
1147
|
status: "ok",
|
|
825
|
-
content:
|
|
1148
|
+
content: finalContent,
|
|
826
1149
|
totalLines: totalFileLines,
|
|
827
|
-
|
|
1150
|
+
warning: safetyWarning,
|
|
1151
|
+
truncated: finalTruncated || undefined,
|
|
828
1152
|
nextOffset,
|
|
829
1153
|
});
|
|
830
1154
|
counts.read++;
|
|
@@ -995,11 +1319,108 @@ function buildSummary(
|
|
|
995
1319
|
return parts.join("\n");
|
|
996
1320
|
}
|
|
997
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
|
+
|
|
998
1417
|
function buildContentText(summary: string, results: OpResult[]): string {
|
|
999
1418
|
const sections: string[] = [summary];
|
|
1000
1419
|
|
|
1001
1420
|
for (const r of results) {
|
|
1002
|
-
if (r.op === "read" && r.status === "ok" && r.
|
|
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) {
|
|
1003
1424
|
const lineInfo = r.totalLines !== undefined ? ` (${r.totalLines} lines)` : "";
|
|
1004
1425
|
sections.push(`\n--- ${r.path}${lineInfo} ---\n${r.content}`);
|
|
1005
1426
|
} else if (r.op === "edit" && r.status === "ok") {
|
|
@@ -1021,6 +1442,147 @@ function buildContentText(summary: string, results: OpResult[]): string {
|
|
|
1021
1442
|
// Tool definition factory
|
|
1022
1443
|
// ---------------------------------------------------------------------------
|
|
1023
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
|
+
|
|
1024
1586
|
export function createBatchTool() {
|
|
1025
1587
|
return {
|
|
1026
1588
|
name: "batch",
|
|
@@ -1079,5 +1641,9 @@ export function createBatchTool() {
|
|
|
1079
1641
|
details: { results },
|
|
1080
1642
|
};
|
|
1081
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),
|
|
1082
1648
|
};
|
|
1083
1649
|
}
|