pi-agent-flow 1.2.0 → 1.2.3
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 +1 -1
- package/agents/audit.md +2 -27
- package/agents/build.md +2 -27
- package/agents/craft.md +2 -26
- package/agents/debug.md +0 -26
- package/agents/ideas.md +2 -25
- package/agents/scout.md +3 -28
- package/package.json +1 -1
- package/src/ambient.d.ts +18 -1
- package/src/batch.ts +445 -17
- package/src/config.ts +5 -0
- package/src/flow.ts +54 -3
- package/src/index.ts +169 -2
- package/src/render.ts +39 -0
- package/src/structured-output.ts +97 -0
- package/src/timed-bash.ts +129 -0
- package/src/types.ts +98 -0
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,
|
|
111
|
-
*
|
|
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
|
|
184
|
-
//
|
|
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
|
|
206
|
-
//
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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:
|
|
1148
|
+
content: finalContent,
|
|
753
1149
|
totalLines: totalFileLines,
|
|
754
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
}
|