pi-agent-flow 1.2.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/src/batch.ts CHANGED
@@ -84,6 +84,14 @@ interface FileOpInput {
84
84
  l?: number;
85
85
  }
86
86
 
87
+ interface ContextMapEntry {
88
+ kind: string;
89
+ name: string;
90
+ startLine: number;
91
+ endLine: number;
92
+ parent?: string;
93
+ }
94
+
87
95
  interface OpResult {
88
96
  op: "read" | "write" | "edit" | "delete";
89
97
  path: string;
@@ -92,6 +100,11 @@ interface OpResult {
92
100
  bytes?: number;
93
101
  blocksChanged?: number;
94
102
  totalLines?: number;
103
+ contextMap?: boolean;
104
+ language?: string;
105
+ symbols?: ContextMapEntry[];
106
+ symbolsTruncated?: boolean;
107
+ warning?: string;
95
108
  truncated?: boolean;
96
109
  nextOffset?: number;
97
110
  error?: string;
@@ -107,8 +120,9 @@ interface ReadTruncationResult {
107
120
 
108
121
  interface ReadOptions {
109
122
  /**
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.
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.
112
126
  */
113
127
  truncate?: boolean;
114
128
  toolName?: "batch" | "batch_read";
@@ -125,6 +139,9 @@ interface ExecuteOptions {
125
139
 
126
140
  const MAX_LINES = 2000;
127
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;
128
145
 
129
146
  function normalizeToLF(text: string): string {
130
147
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -148,6 +165,355 @@ function stripBom(content: string): { bom: string; text: string } {
148
165
  : { bom: "", text: content };
149
166
  }
150
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
+
151
517
  function readWithOffsetLimit(
152
518
  content: string,
153
519
  offset?: number,
@@ -180,9 +546,8 @@ function readWithOffsetLimit(
180
546
  let truncated = false;
181
547
  let nextOffset: number | undefined;
182
548
 
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.
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.
186
551
  if (shouldTruncate && selectedLines.length > MAX_LINES) {
187
552
  selectedLines = selectedLines.slice(0, MAX_LINES);
188
553
  truncated = true;
@@ -202,8 +567,8 @@ function readWithOffsetLimit(
202
567
  // Join and check byte size
203
568
  let result = selectedLines.join("\n");
204
569
 
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.
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.
207
572
  if (shouldTruncate && Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
208
573
  let byteAccum = 0;
209
574
  let keepLines = 0;
@@ -732,11 +1097,42 @@ async function executeOperations(
732
1097
  const allLines = text.split("\n");
733
1098
  const totalFileLines = allLines.length;
734
1099
 
735
- const { content: readContent, truncated, nextOffset, linesRead } =
736
- readWithOffsetLimit(text, op.s, op.l, op.p, options.readOptions);
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
+ }
737
1126
 
738
- if (truncated || (includeLimitWarnings && op.l !== undefined && (op.s ?? 1) - 1 + op.l < totalFileLines)) {
739
- const shownLines = truncated ? linesRead : op.l!;
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!;
740
1136
  truncatedFiles.push({
741
1137
  path: op.p,
742
1138
  shown: shownLines,
@@ -749,9 +1145,10 @@ async function executeOperations(
749
1145
  op: "read",
750
1146
  path: op.p,
751
1147
  status: "ok",
752
- content: readContent,
1148
+ content: finalContent,
753
1149
  totalLines: totalFileLines,
754
- truncated: truncated || undefined,
1150
+ warning: safetyWarning,
1151
+ truncated: finalTruncated || undefined,
755
1152
  nextOffset,
756
1153
  });
757
1154
  counts.read++;
@@ -990,11 +1387,40 @@ function renderBatchResult(
990
1387
  return new Text(fullText, 0, 0);
991
1388
  }
992
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
+
993
1417
  function buildContentText(summary: string, results: OpResult[]): string {
994
1418
  const sections: string[] = [summary];
995
1419
 
996
1420
  for (const r of results) {
997
- if (r.op === "read" && r.status === "ok" && r.content) {
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) {
998
1424
  const lineInfo = r.totalLines !== undefined ? ` (${r.totalLines} lines)` : "";
999
1425
  sections.push(`\n--- ${r.path}${lineInfo} ---\n${r.content}`);
1000
1426
  } else if (r.op === "edit" && r.status === "ok") {
@@ -1071,7 +1497,8 @@ export function createBatchReadTool() {
1071
1497
  description: [
1072
1498
  "Batch read-only file operations — run multiple read ops in a single call.",
1073
1499
  "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.",
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.`,
1075
1502
  "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
1076
1503
  "Best for reading multiple files or sections in one call.",
1077
1504
  ].join("\n"),
@@ -1079,8 +1506,9 @@ export function createBatchReadTool() {
1079
1506
  promptGuidelines: [
1080
1507
  "Use batch_read to perform multiple file reads in a single call rather than separate tool calls.",
1081
1508
  "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.",
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.",
1084
1512
  ],
1085
1513
  parameters: BatchReadParams,
1086
1514
  prepareArguments: prepareBatchReadArguments,
package/src/config.ts CHANGED
@@ -27,6 +27,8 @@ export interface LoadedFlowModelConfigs {
27
27
 
28
28
  export interface FlowSettings {
29
29
  toolOptimize?: boolean;
30
+ /** Whether to inject structured JSON output instructions into flow prompts. Default: true. */
31
+ structuredOutput?: boolean;
30
32
  }
31
33
 
32
34
  const BUILTIN_FLOW_MODEL_CONFIGS: FlowModelConfigs = {
@@ -171,6 +173,9 @@ function extractFlowSettings(settings: Record<string, unknown> | null): FlowSett
171
173
  if (typeof obj.toolOptimize === "boolean") {
172
174
  result.toolOptimize = obj.toolOptimize;
173
175
  }
176
+ if (typeof obj.structuredOutput === "boolean") {
177
+ result.structuredOutput = obj.structuredOutput;
178
+ }
174
179
  return result;
175
180
  }
176
181
 
package/src/flow.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  getFlowOutput,
21
21
  normalizeFlowResult,
22
22
  } from "./types.js";
23
+ import { extractStructuredOutput } from "./structured-output.js";
23
24
 
24
25
  const isWindows = process.platform === "win32";
25
26
  const SIGKILL_TIMEOUT_MS = 5000;
@@ -132,6 +133,7 @@ function buildFlowArgs(
132
133
  parentDepth: number = 0,
133
134
  maxDepth: number = 0,
134
135
  toolOptimize: boolean = false,
136
+ structuredOutput: boolean = true,
135
137
  ): string[] {
136
138
  const args: string[] = [
137
139
  "--mode",
@@ -210,8 +212,42 @@ function buildFlowArgs(
210
212
  `</activation>`;
211
213
 
212
214
  // Phase 3: Directive — the flow's system prompt (renamed from <system-directive>)
213
- const directive = flow.systemPrompt.trim()
214
- ? `\n\n<directive>\n${flow.systemPrompt.trim()}\n</directive>`
215
+ let directiveBody = flow.systemPrompt.trim();
216
+
217
+ // Append structured output instructions when enabled
218
+ if (structuredOutput && directiveBody) {
219
+ directiveBody +=
220
+ `\n\n## Structured Output\n\n` +
221
+ `End your response with a JSON code block containing:\n` +
222
+ `\n` +
223
+ `\`\`\`json\n` +
224
+ `{\n` +
225
+ ` "version": "1.0",\n` +
226
+ ` "status": "complete",\n` +
227
+ ` "summary": "2-3 sentence summary of what was accomplished",\n` +
228
+ ` "files": [\n` +
229
+ ` { "path": "relative/path", "role": "read", "description": "why it matters", "snippet": "short excerpt", "ranges": [{ "start": 10, "end": 25, "label": "bug" }] }\n` +
230
+ ` ],\n` +
231
+ ` "actions": [\n` +
232
+ ` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
233
+ ` ],\n` +
234
+ ` "commands": [\n` +
235
+ ` { "command": "npm test", "tool": "bash", "target": ".", "result": "success", "output": "12 passing, 2 failing", "purpose": "Run test suite to verify fix" }\n` +
236
+ ` ],\n` +
237
+ ` "notDone": [\n` +
238
+ ` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
239
+ ` ],\n` +
240
+ ` "nextSteps": ["recommended follow-up action"],\n` +
241
+ ` "reasoning": ["key hypothesis or inference"],\n` +
242
+ ` "notes": ["observation or warning"]\n` +
243
+ `}\n` +
244
+ `\`\`\`\n` +
245
+ `\n` +
246
+ `Only include fields that have data. Omit empty arrays; missing array fields are acceptable. Keep snippets under 300 characters. List at most 10 files, 10 actions, 10 commands, and 10 notDone items. If you cannot produce valid structured output, omit the JSON block entirely.`;
247
+ }
248
+
249
+ const directive = directiveBody
250
+ ? `\n\n<directive>\n${directiveBody}\n</directive>`
215
251
  : "";
216
252
 
217
253
  // Phase 4: Mission — the intent wrapped with execution contract
@@ -256,6 +292,8 @@ export interface RunFlowOptions {
256
292
  preventCycles: boolean;
257
293
  /** Whether to transform tool lists to use batch. */
258
294
  toolOptimize?: boolean;
295
+ /** Whether to inject structured JSON output instructions. Default: true. */
296
+ structuredOutput?: boolean;
259
297
  /** Explicit model to use for this flow execution. */
260
298
  model?: string;
261
299
  /** Abort signal for cancellation. */
@@ -287,6 +325,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
287
325
  maxDepth,
288
326
  preventCycles,
289
327
  toolOptimize = false,
328
+ structuredOutput = true,
290
329
  model,
291
330
  signal,
292
331
  onUpdate,
@@ -367,6 +406,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
367
406
  parentDepth,
368
407
  maxDepth,
369
408
  toolOptimize,
409
+ structuredOutput,
370
410
  );
371
411
  let wasAborted = false;
372
412
 
@@ -532,7 +572,18 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
532
572
  result.usage.smoothedTps = finalSmoothedTps;
533
573
  }
534
574
 
535
- return normalizeFlowResult(result, wasAborted);
575
+ const normalized = normalizeFlowResult(result, wasAborted);
576
+
577
+ // Extract structured JSON output from the final assistant text
578
+ if (structuredOutput) {
579
+ const flowText = getFlowOutput(normalized.messages);
580
+ const extracted = extractStructuredOutput(flowText);
581
+ if (extracted) {
582
+ normalized.structuredOutput = extracted;
583
+ }
584
+ }
585
+
586
+ return normalized;
536
587
  } finally {
537
588
  cleanupFlowTempDir(forkSessionTmpDir);
538
589
  }