pi-agent-flow 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -101,6 +102,21 @@ interface ReadTruncationResult {
101
102
  content: string;
102
103
  truncated: boolean;
103
104
  nextOffset?: number;
105
+ linesRead: number;
106
+ }
107
+
108
+ interface ReadOptions {
109
+ /**
110
+ * When false, read operations ignore MAX_LINES and total MAX_BYTES caps.
111
+ * A single selected line that exceeds MAX_BYTES still fails with a targeted error.
112
+ */
113
+ truncate?: boolean;
114
+ toolName?: "batch" | "batch_read";
115
+ }
116
+
117
+ interface ExecuteOptions {
118
+ readOptions?: ReadOptions;
119
+ includeLimitWarnings?: boolean;
104
120
  }
105
121
 
106
122
  // ---------------------------------------------------------------------------
@@ -137,9 +153,12 @@ function readWithOffsetLimit(
137
153
  offset?: number,
138
154
  limit?: number,
139
155
  filePath?: string,
156
+ options: ReadOptions = {},
140
157
  ): ReadTruncationResult {
141
158
  const allLines = content.split("\n");
142
159
  const totalFileLines = allLines.length;
160
+ const shouldTruncate = options.truncate !== false;
161
+ const toolName = options.toolName ?? "batch";
143
162
 
144
163
  // Validate offset
145
164
  if (offset !== undefined && offset > totalFileLines) {
@@ -161,25 +180,31 @@ function readWithOffsetLimit(
161
180
  let truncated = false;
162
181
  let nextOffset: number | undefined;
163
182
 
164
- // Apply max-lines cap
165
- if (selectedLines.length > MAX_LINES) {
183
+ // Apply max-lines cap for regular batch reads. batch_read intentionally
184
+ // bypasses this cap so each read operation can return the full requested file
185
+ // or range.
186
+ if (shouldTruncate && selectedLines.length > MAX_LINES) {
166
187
  selectedLines = selectedLines.slice(0, MAX_LINES);
167
188
  truncated = true;
168
189
  }
169
190
 
191
+ // A single selected line that exceeds the byte cap is not safely splittable by
192
+ // line-oriented offsets, so keep the existing hard error in both modes.
193
+ for (let i = 0; i < selectedLines.length; i++) {
194
+ if (Buffer.byteLength(selectedLines[i], "utf-8") > MAX_BYTES) {
195
+ const lineDisplay = startLine + i + 1;
196
+ throw new Error(
197
+ `Line ${lineDisplay} exceeds limit. Try: ${toolName} with o:"read", s:${lineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
198
+ );
199
+ }
200
+ }
201
+
170
202
  // Join and check byte size
171
203
  let result = selectedLines.join("\n");
172
204
 
173
- // If first line alone exceeds byte limit, give a specific hint
174
- if (selectedLines.length >= 1 && Buffer.byteLength(selectedLines[0], "utf-8") > MAX_BYTES) {
175
- const startLineDisplay = startLine + 1;
176
- throw new Error(
177
- `Line ${startLineDisplay} exceeds limit. Try: batch with o:"read", s:${startLineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
178
- );
179
- }
180
-
181
- // Truncate by bytes if needed
182
- if (Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
205
+ // Truncate by total bytes for regular batch reads only. batch_read keeps the
206
+ // complete selected multi-line content unless an individual line is too long.
207
+ if (shouldTruncate && Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
183
208
  let byteAccum = 0;
184
209
  let keepLines = 0;
185
210
  for (let i = 0; i < selectedLines.length; i++) {
@@ -208,7 +233,7 @@ function readWithOffsetLimit(
208
233
  result += `\n\n[${remaining} more lines in file. Use s=${nextOffset} to continue.]`;
209
234
  }
210
235
 
211
- return { content: result, truncated, nextOffset };
236
+ return { content: result, truncated, nextOffset, linesRead: selectedLines.length };
212
237
  }
213
238
 
214
239
  function levenshtein(a: string, b: string): number {
@@ -277,8 +302,6 @@ function getErrorHint(error: string): string {
277
302
  return "Merge overlapping edits into one.";
278
303
  if (error.includes("No changes"))
279
304
  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
305
  if (error.includes("is not readable") || error.includes("not readable"))
283
306
  return "Check file permissions.";
284
307
  if (error.includes("ENOENT") || error.includes("no such file"))
@@ -557,101 +580,7 @@ export function isWithinDirectory(child: string, parent: string): boolean {
557
580
 
558
581
  async function validatePath(inputPath: string, cwd: string): Promise<string> {
559
582
  const expandedPath = expandTilde(inputPath);
560
- const resolved = path.resolve(cwd, expandedPath);
561
- const normalizedResolved = path.normalize(resolved);
562
- const normalizedCwd = path.normalize(cwd);
563
- if (
564
- normalizedResolved !== normalizedCwd &&
565
- !isWithinDirectory(normalizedResolved, normalizedCwd)
566
- ) {
567
- throw new Error(
568
- `Path traversal detected: ${inputPath} resolves outside working directory.`,
569
- );
570
- }
571
-
572
- // Resolve cwd and file symlinks to prevent traversal via symlink targets.
573
- // cwd must also be resolved (e.g. macOS /var -> /private/var).
574
- const realCwd = await fs.realpath(cwd);
575
-
576
- let realPath: string;
577
- try {
578
- realPath = await fs.realpath(resolved);
579
- } catch {
580
- const normalizedRealCwd = path.normalize(realCwd);
581
-
582
- // Check if the final component is a broken symlink pointing outside cwd.
583
- try {
584
- const lstat = await fs.lstat(resolved);
585
- if (lstat.isSymbolicLink()) {
586
- const linkTarget = await fs.readlink(resolved);
587
- const realLinkDir = await fs.realpath(path.dirname(resolved));
588
- const resolvedTarget = path.resolve(realLinkDir, linkTarget);
589
- const normalizedTarget = path.normalize(resolvedTarget);
590
- if (
591
- normalizedTarget !== normalizedRealCwd &&
592
- !isWithinDirectory(normalizedTarget, normalizedRealCwd)
593
- ) {
594
- throw new Error(
595
- `Path traversal detected: ${inputPath} symlink points outside working directory.`,
596
- );
597
- }
598
- return resolved;
599
- }
600
- } catch (lstatErr: any) {
601
- if (lstatErr.code !== "ENOENT") throw lstatErr;
602
- // Not a symlink, proceed to ancestor fallback
603
- }
604
-
605
- // File doesn't exist yet (e.g. write creates new file).
606
- // Walk up to the nearest existing ancestor and validate it is within realCwd.
607
- let ancestor = path.dirname(resolved);
608
- let ancestorReal: string | null = null;
609
- while (ancestor && ancestor !== path.dirname(ancestor)) {
610
- try {
611
- ancestorReal = await fs.realpath(ancestor);
612
- break;
613
- } catch {
614
- ancestor = path.dirname(ancestor);
615
- }
616
- }
617
- if (!ancestorReal) {
618
- throw new Error(`Path not found: ${inputPath}`);
619
- }
620
- const normalizedAncestor = path.normalize(ancestorReal);
621
- if (
622
- normalizedAncestor !== normalizedRealCwd &&
623
- !isWithinDirectory(normalizedAncestor, normalizedRealCwd)
624
- ) {
625
- throw new Error(
626
- `Path traversal detected: ${inputPath} ancestor directory is outside working directory.`,
627
- );
628
- }
629
- return resolved;
630
- }
631
-
632
- // Validate resolved real path is within realCwd
633
- const normalizedReal = path.normalize(realPath);
634
- const normalizedRealCwd = path.normalize(realCwd);
635
- if (
636
- normalizedReal !== normalizedRealCwd &&
637
- !isWithinDirectory(normalizedReal, normalizedRealCwd)
638
- ) {
639
- throw new Error(
640
- `Path traversal detected: ${inputPath} symlink points outside working directory.`,
641
- );
642
- }
643
-
644
- // If the requested path is a symlink, return the original path so that
645
- // operations like delete can act on the symlink itself.
646
- try {
647
- const lstat = await fs.lstat(resolved);
648
- if (lstat.isSymbolicLink()) {
649
- return resolved;
650
- }
651
- } catch {
652
- // ignore
653
- }
654
- return realPath;
583
+ return path.resolve(cwd, expandedPath);
655
584
  }
656
585
 
657
586
  // ---------------------------------------------------------------------------
@@ -758,6 +687,7 @@ async function executeOperations(
758
687
  operations: FileOpInput[],
759
688
  cwd: string,
760
689
  signal?: AbortSignal,
690
+ options: ExecuteOptions = {},
761
691
  ): Promise<{ summary: string; contentText: string; results: OpResult[] }> {
762
692
  const results: OpResult[] = [];
763
693
  let failed = false;
@@ -765,6 +695,7 @@ async function executeOperations(
765
695
  const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
766
696
  const errors: { path: string; op: string; message: string; hint?: string }[] = [];
767
697
  const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
698
+ const includeLimitWarnings = options.includeLimitWarnings ?? true;
768
699
 
769
700
  for (const op of operations) {
770
701
  if (signal?.aborted) {
@@ -801,15 +732,11 @@ async function executeOperations(
801
732
  const allLines = text.split("\n");
802
733
  const totalFileLines = allLines.length;
803
734
 
804
- const { content: readContent, truncated, nextOffset } =
805
- readWithOffsetLimit(text, op.s, op.l, op.p);
735
+ const { content: readContent, truncated, nextOffset, linesRead } =
736
+ readWithOffsetLimit(text, op.s, op.l, op.p, options.readOptions);
806
737
 
807
- if (truncated || (op.l !== undefined && (op.s ?? 1) - 1 + op.l < totalFileLines)) {
808
- const shownLines = truncated
809
- ? (op.l !== undefined
810
- ? Math.min(op.l, MAX_LINES)
811
- : MAX_LINES)
812
- : op.l!;
738
+ if (truncated || (includeLimitWarnings && op.l !== undefined && (op.s ?? 1) - 1 + op.l < totalFileLines)) {
739
+ const shownLines = truncated ? linesRead : op.l!;
813
740
  truncatedFiles.push({
814
741
  path: op.p,
815
742
  shown: shownLines,
@@ -995,6 +922,74 @@ function buildSummary(
995
922
  return parts.join("\n");
996
923
  }
997
924
 
925
+ function shortenPath(p: string): string {
926
+ const home = os.homedir();
927
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
928
+ }
929
+
930
+ type BatchTheme = {
931
+ fg: (color: string, text: string) => string;
932
+ bold: (s: string) => string;
933
+ bg: (color: string, text: string) => string;
934
+ };
935
+
936
+ function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: string; e?: unknown[] }> {
937
+ let rawOps: unknown[];
938
+ if (Array.isArray(args.o)) rawOps = args.o;
939
+ else if (Array.isArray(args.op)) rawOps = args.op;
940
+ else if (Array.isArray(args.operations)) rawOps = args.operations;
941
+ else if (Array.isArray(args)) rawOps = args;
942
+ else rawOps = [];
943
+
944
+ return rawOps
945
+ .filter((op): op is Record<string, unknown> => !!op && typeof op === "object")
946
+ .map((op) => {
947
+ const opName = String(op.o ?? op.op ?? "?");
948
+ const opPath = String(op.p ?? op.path ?? "?");
949
+ const edits = Array.isArray(op.e) ? op.e : Array.isArray(op.edits) ? op.edits : undefined;
950
+ return { o: opName, p: opPath, e: edits };
951
+ });
952
+ }
953
+
954
+ function formatBatchCall(args: Record<string, unknown>): string {
955
+ const ops = extractBatchOps(args);
956
+ if (ops.length === 0) return "batch (empty)";
957
+
958
+ const parts: string[] = [];
959
+ for (const op of ops) {
960
+ const shortPath = shortenPath(op.p);
961
+ if (op.o === "edit" && op.e && op.e.length > 1) {
962
+ parts.push(`edit ${shortPath} (${op.e.length} blocks)`);
963
+ } else {
964
+ parts.push(`${op.o} ${shortPath}`);
965
+ }
966
+ }
967
+
968
+ if (parts.length <= 3) {
969
+ return parts.join(", ");
970
+ }
971
+ return `${parts.slice(0, 2).join(", ")} +${parts.length - 2} more`;
972
+ }
973
+
974
+ function renderBatchCall(args: Record<string, unknown>, theme: BatchTheme): Text {
975
+ const summary = formatBatchCall(args);
976
+ return new Text(theme.fg("muted", "batch ") + theme.fg("accent", summary), 0, 0);
977
+ }
978
+
979
+ function renderBatchResult(
980
+ result: { content?: Array<{ type: string; text?: string }> },
981
+ expanded: boolean,
982
+ _theme: BatchTheme,
983
+ _args?: Record<string, unknown>,
984
+ ): Text | TruncatedText {
985
+ const fullText = result.content?.find((c) => c.type === "text")?.text ?? "";
986
+ if (!expanded) {
987
+ const summary = fullText.split("\n")[0] ?? "";
988
+ return new TruncatedText(summary, 0, 0);
989
+ }
990
+ return new Text(fullText, 0, 0);
991
+ }
992
+
998
993
  function buildContentText(summary: string, results: OpResult[]): string {
999
994
  const sections: string[] = [summary];
1000
995
 
@@ -1021,6 +1016,145 @@ function buildContentText(summary: string, results: OpResult[]): string {
1021
1016
  // Tool definition factory
1022
1017
  // ---------------------------------------------------------------------------
1023
1018
 
1019
+ export const BatchReadParams = Type.Object({
1020
+ o: Type.Array(
1021
+ Type.Object({
1022
+ o: Type.Literal("read"),
1023
+ p: Type.String({ description: "Path to the file (relative or absolute)" }),
1024
+ s: Type.Optional(
1025
+ Type.Number({
1026
+ minimum: 1,
1027
+ description:
1028
+ "1-indexed line number to start reading from (offset). Used with o: 'read'.",
1029
+ }),
1030
+ ),
1031
+ l: Type.Optional(
1032
+ Type.Number({
1033
+ minimum: 1,
1034
+ description:
1035
+ "Maximum number of lines to read (limit). Used with o: 'read'.",
1036
+ }),
1037
+ ),
1038
+ }),
1039
+ {
1040
+ description:
1041
+ "Ordered list of read operations. Executed sequentially. On failure, remaining operations are skipped.",
1042
+ },
1043
+ ),
1044
+ });
1045
+
1046
+ function prepareBatchReadArguments(input: unknown): { o: FileOpInput[] } | unknown {
1047
+ const prepared = prepareArguments(input);
1048
+ const ops = Array.isArray(prepared) ? prepared : (prepared as { o: unknown[] }).o;
1049
+ if (!Array.isArray(ops)) return { o: [] };
1050
+
1051
+ for (const op of ops) {
1052
+ if (!op || typeof op !== "object") continue;
1053
+ const obj = op as Record<string, unknown>;
1054
+ const opType = String(obj.o ?? obj.op ?? "").toLowerCase();
1055
+ if (opType && opType !== "read") {
1056
+ throw new Error(`batch_read only supports read operations. Received: ${opType}`);
1057
+ }
1058
+ }
1059
+ return prepared;
1060
+ }
1061
+
1062
+ function renderBatchReadCall(args: Record<string, unknown>, theme: BatchTheme): Text {
1063
+ const summary = formatBatchCall(args);
1064
+ return new Text(theme.fg("muted", "batch_read ") + theme.fg("accent", summary), 0, 0);
1065
+ }
1066
+
1067
+ export function createBatchReadTool() {
1068
+ return {
1069
+ name: "batch_read",
1070
+ label: "batch_read",
1071
+ description: [
1072
+ "Batch read-only file operations — run multiple read ops in a single call.",
1073
+ "Each operation is independent and executes sequentially in array order; on failure, remaining operations are skipped.",
1074
+ "Reads are not truncated by the batch MAX_LINES or total MAX_BYTES caps; a single line over the byte limit still errors.",
1075
+ "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
1076
+ "Best for reading multiple files or sections in one call.",
1077
+ ].join("\n"),
1078
+ promptSnippet: "Batch read-only file operations — run multiple read ops in one call",
1079
+ promptGuidelines: [
1080
+ "Use batch_read to perform multiple file reads in a single call rather than separate tool calls.",
1081
+ "Prefer batch_read when reading 2+ files or multiple sections of the same file.",
1082
+ "batch_read returns the full requested content except when an individual line exceeds the byte limit.",
1083
+ "For single-file reads, the read tool is fine; batch_read shines for multi-file reading.",
1084
+ ],
1085
+ parameters: BatchReadParams,
1086
+ prepareArguments: prepareBatchReadArguments,
1087
+
1088
+ async execute(
1089
+ _toolCallId: string,
1090
+ input: unknown,
1091
+ signal: AbortSignal | undefined,
1092
+ _onUpdate: unknown,
1093
+ ctx: { cwd: string },
1094
+ ) {
1095
+ let prepared: unknown;
1096
+ try {
1097
+ prepared = prepareBatchReadArguments(input);
1098
+ } catch (err) {
1099
+ const message = err instanceof Error ? err.message : String(err);
1100
+ return {
1101
+ content: [{ type: "text", text: `Error: ${message}` }],
1102
+ isError: true,
1103
+ };
1104
+ }
1105
+
1106
+ const ops = Array.isArray(prepared)
1107
+ ? (prepared as FileOpInput[])
1108
+ : (prepared as { o: FileOpInput[] }).o;
1109
+
1110
+ if (!Array.isArray(ops) || ops.length === 0) {
1111
+ return {
1112
+ content: [
1113
+ { type: "text", text: "Error: o array is required and must not be empty." },
1114
+ ],
1115
+ isError: true,
1116
+ };
1117
+ }
1118
+
1119
+ // Defensive validation: reject any non-read operations
1120
+ for (const op of ops) {
1121
+ if (op.o !== "read") {
1122
+ return {
1123
+ content: [
1124
+ {
1125
+ type: "text",
1126
+ text: `Error: batch_read only supports read operations. Received ${op.o} for ${op.p}.`,
1127
+ },
1128
+ ],
1129
+ isError: true,
1130
+ };
1131
+ }
1132
+ }
1133
+
1134
+ if (signal?.aborted) {
1135
+ return {
1136
+ content: [{ type: "text", text: "Operation aborted." }],
1137
+ isError: true,
1138
+ };
1139
+ }
1140
+
1141
+ const { contentText, results } = await executeOperations(ops, ctx.cwd, signal, {
1142
+ readOptions: { truncate: false, toolName: "batch_read" },
1143
+ includeLimitWarnings: false,
1144
+ });
1145
+
1146
+ return {
1147
+ content: [{ type: "text", text: contentText }],
1148
+ details: { results },
1149
+ };
1150
+ },
1151
+
1152
+ renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchReadCall(args, theme),
1153
+ renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
1154
+ renderBatchResult(result, expanded, theme, args),
1155
+ };
1156
+ }
1157
+
1024
1158
  export function createBatchTool() {
1025
1159
  return {
1026
1160
  name: "batch",
@@ -1079,5 +1213,9 @@ export function createBatchTool() {
1079
1213
  details: { results },
1080
1214
  };
1081
1215
  },
1216
+
1217
+ renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchCall(args, theme),
1218
+ renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
1219
+ renderBatchResult(result, expanded, theme, args),
1082
1220
  };
1083
1221
  }