pi-agent-flow 1.2.4 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents/debug.md CHANGED
@@ -24,3 +24,6 @@ During this debug flow — your mission is to investigate root cause. Be forensi
24
24
  - Use `batch` with `o: "read"`, `s: <offset>`, and `l: <limit>` for targeted file reading instead of bash `sed`/`head`/`tail`.
25
25
  - Do not suggest fixes until root cause is confirmed.
26
26
  - Do not implement changes from this flow unless explicitly requested.
27
+
28
+ ## Note
29
+ Treat this as a clean-slate system rewrite, unless explicitly mentioned in the requirements. Perform a comprehensive migration with zero requirements for backwards compatibility. You must ensure that all residual code, variable names, test suites, and documentation are fully refactored and perfectly aligned with the new architecture.
package/agents/scout.md CHANGED
@@ -24,3 +24,6 @@ During this scout flow — your mission is to discover relevant context. Move fa
24
24
  - Include relevant snippets or evidence inline so citations are verifiable.
25
25
  - Show actual code/data, not excessive summaries.
26
26
  - If something is not found, say so directly — do not guess.
27
+
28
+ ## Note
29
+ Treat this as a clean-slate system rewrite, unless explicitly mentioned in the requirements. Perform a comprehensive migration with zero requirements for backwards compatibility. You must ensure that all residual code, variable names, test suites, and documentation are fully refactored and perfectly aligned with the new architecture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/agents.ts CHANGED
@@ -185,6 +185,17 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
185
185
  );
186
186
  }
187
187
 
188
+ // Tier: prefer explicit frontmatter, fall back to name-based inference
189
+ let tier: FlowTier | undefined;
190
+ if (typeof frontmatter.tier === "string") {
191
+ const normalized = frontmatter.tier.trim().toLowerCase();
192
+ if (normalized === "lite" || normalized === "flash" || normalized === "full") {
193
+ tier = normalized;
194
+ } else {
195
+ console.warn(`[pi-agent-flow] Ignoring invalid tier "${frontmatter.tier}" in "${filePath}". Expected lite, flash, or full.`);
196
+ }
197
+ }
198
+
188
199
  return {
189
200
  name,
190
201
  description,
@@ -193,7 +204,7 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
193
204
  thinking: typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
194
205
  maxDepth,
195
206
  inheritContext,
196
- tier: getFlowTier(name),
207
+ tier: tier ?? getFlowTier(name),
197
208
  systemPrompt: body,
198
209
  source,
199
210
  filePath,
package/src/ambient.d.ts CHANGED
@@ -6,6 +6,7 @@ declare module "@mariozechner/pi-coding-agent" {
6
6
  getFlag(name: string): unknown;
7
7
  getActiveTools(): string[];
8
8
  on(event: string, callback: (...args: any[]) => any): void;
9
+ emit(event: string, ...args: any[]): void;
9
10
  registerTool(tool: {
10
11
  name: string;
11
12
  label: string;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * batch — constants and shared types.
3
+ */
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Limits
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const MAX_LINES = 2000;
10
+ export const MAX_BYTES = 50 * 1024; // 50KB
11
+ export const SAFE_FULL_READ_LIMIT = 300;
12
+ export const TARGETED_READ_LINE_LIMIT = 1000;
13
+ export const MAX_CONTEXT_MAP_ENTRIES = 100;
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface EditReplacement {
20
+ f: string;
21
+ r: string;
22
+ }
23
+
24
+ export interface FileOpInput {
25
+ o: "read" | "write" | "edit" | "delete";
26
+ p: string;
27
+ c?: string;
28
+ e?: EditReplacement[];
29
+ s?: number;
30
+ l?: number;
31
+ }
32
+
33
+ export interface ContextMapEntry {
34
+ kind: string;
35
+ name: string;
36
+ startLine: number;
37
+ endLine: number;
38
+ parent?: string;
39
+ }
40
+
41
+ export interface OpResult {
42
+ op: "read" | "write" | "edit" | "delete";
43
+ path: string;
44
+ status: "ok" | "error" | "skipped";
45
+ content?: string;
46
+ bytes?: number;
47
+ blocksChanged?: number;
48
+ totalLines?: number;
49
+ contextMap?: boolean;
50
+ language?: string;
51
+ symbols?: ContextMapEntry[];
52
+ symbolsTruncated?: boolean;
53
+ warning?: string;
54
+ truncated?: boolean;
55
+ nextOffset?: number;
56
+ error?: string;
57
+ hint?: string;
58
+ }
59
+
60
+ export interface ReadTruncationResult {
61
+ content: string;
62
+ truncated: boolean;
63
+ nextOffset?: number;
64
+ linesRead: number;
65
+ }
66
+
67
+ export interface ReadOptions {
68
+ /**
69
+ * When false, readWithOffsetLimit ignores regular batch MAX_LINES and total
70
+ * MAX_BYTES caps. batch_read applies its own safe full-file and targeted-read
71
+ * guards before calling this helper.
72
+ */
73
+ truncate?: boolean;
74
+ toolName?: "batch" | "batch_read";
75
+ }
76
+
77
+ export interface ExecuteOptions {
78
+ readOptions?: ReadOptions;
79
+ includeLimitWarnings?: boolean;
80
+ }
81
+
82
+ export type ContextLanguage =
83
+ | "typescript"
84
+ | "javascript"
85
+ | "python"
86
+ | "terraform"
87
+ | "hcl"
88
+ | "yaml"
89
+ | "dockerfile"
90
+ | "plain";
91
+
92
+ export interface FileContextMap {
93
+ language: ContextLanguage;
94
+ symbols: ContextMapEntry[];
95
+ symbolsTruncated?: boolean;
96
+ }
97
+
98
+ export type BatchTheme = {
99
+ fg: (color: string, text: string) => string;
100
+ bold: (s: string) => string;
101
+ bg: (color: string, text: string) => string;
102
+ };
@@ -0,0 +1,526 @@
1
+ /**
2
+ * batch — operation execution engine.
3
+ *
4
+ * Orchestrates sequential file operations (read/write/edit/delete) with
5
+ * skip-on-failure semantics, error enrichment, and result summarisation.
6
+ */
7
+
8
+ import * as fs from "node:fs/promises";
9
+ import * as path from "node:path";
10
+ import {
11
+ type FileOpInput,
12
+ type ExecuteOptions,
13
+ type ReadOptions,
14
+ type OpResult,
15
+ MAX_LINES,
16
+ MAX_BYTES,
17
+ SAFE_FULL_READ_LIMIT,
18
+ TARGETED_READ_LINE_LIMIT,
19
+ } from "./constants.js";
20
+ import {
21
+ normalizeToLF,
22
+ restoreLineEndings,
23
+ detectLineEnding,
24
+ stripBom,
25
+ applyEdits,
26
+ levenshtein,
27
+ expandTilde,
28
+ validatePath,
29
+ } from "./fuzzy-edit.js";
30
+ import { buildFileContextMap } from "./symbols.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Read helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function isBatchRead(options: ExecuteOptions): boolean {
37
+ return options.readOptions?.toolName === "batch_read";
38
+ }
39
+
40
+ function isFullFileRead(op: FileOpInput, totalLines: number): boolean {
41
+ const start = op.s ?? 1;
42
+ if (start !== 1) return false;
43
+ return op.l === undefined || op.l >= totalLines;
44
+ }
45
+
46
+ function buildBatchReadSafetyWarning(): string {
47
+ 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.`;
48
+ }
49
+
50
+ export function readWithOffsetLimit(
51
+ content: string,
52
+ offset?: number,
53
+ limit?: number,
54
+ filePath?: string,
55
+ options: ReadOptions = {},
56
+ ): { content: string; truncated: boolean; nextOffset?: number; linesRead: number } {
57
+ const allLines = content.split("\n");
58
+ const totalFileLines = allLines.length;
59
+ const shouldTruncate = options.truncate !== false;
60
+ const toolName = options.toolName ?? "batch";
61
+
62
+ // Validate offset
63
+ if (offset !== undefined && offset > totalFileLines) {
64
+ throw new Error(
65
+ `Offset ${offset} is beyond end of file (${totalFileLines} lines total)`,
66
+ );
67
+ }
68
+
69
+ // Determine the start line (convert 1-indexed to 0-indexed)
70
+ const startLine = offset !== undefined ? Math.max(0, offset - 1) : 0;
71
+
72
+ // Determine end line
73
+ let endLine = totalFileLines;
74
+ if (limit !== undefined) {
75
+ endLine = Math.min(startLine + limit, totalFileLines);
76
+ }
77
+
78
+ let selectedLines = allLines.slice(startLine, endLine);
79
+ let truncated = false;
80
+ let nextOffset: number | undefined;
81
+
82
+ // Apply max-lines cap for regular batch reads. batch_read clamps oversized
83
+ // targeted reads before this helper and context-maps large full-file reads.
84
+ if (shouldTruncate && selectedLines.length > MAX_LINES) {
85
+ selectedLines = selectedLines.slice(0, MAX_LINES);
86
+ truncated = true;
87
+ }
88
+
89
+ // A single selected line that exceeds the byte cap is not safely splittable by
90
+ // line-oriented offsets, so keep the existing hard error in both modes.
91
+ for (let i = 0; i < selectedLines.length; i++) {
92
+ if (Buffer.byteLength(selectedLines[i], "utf-8") > MAX_BYTES) {
93
+ const lineDisplay = startLine + i + 1;
94
+ throw new Error(
95
+ `Line ${lineDisplay} exceeds limit. Try: ${toolName} with o:"read", s:${lineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ // Join and check byte size
101
+ let result = selectedLines.join("\n");
102
+
103
+ // Truncate by total bytes for regular batch reads only. batch_read relies on
104
+ // its line-oriented safety guards and still rejects an individual huge line.
105
+ if (shouldTruncate && Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
106
+ let byteAccum = 0;
107
+ let keepLines = 0;
108
+ for (let i = 0; i < selectedLines.length; i++) {
109
+ byteAccum += Buffer.byteLength(selectedLines[i], "utf-8") + (i > 0 ? 1 : 0); // newline separator between lines
110
+ if (byteAccum > MAX_BYTES) break;
111
+ keepLines = i + 1;
112
+ }
113
+ selectedLines = selectedLines.slice(0, keepLines);
114
+ result = selectedLines.join("\n");
115
+ truncated = true;
116
+ }
117
+
118
+ // Calculate nextOffset for continuation
119
+ const lastLineRead = startLine + selectedLines.length;
120
+ if (truncated || (limit !== undefined && lastLineRead < totalFileLines)) {
121
+ nextOffset = lastLineRead + 1; // 1-indexed
122
+ }
123
+
124
+ // Append truncation/continuation hints
125
+ if (truncated) {
126
+ const endDisplay = startLine + selectedLines.length;
127
+ const startDisplay = startLine + 1;
128
+ result += `\n\n[Showing lines ${startDisplay}-${endDisplay} of ${totalFileLines}. Use s=${nextOffset} to continue.]`;
129
+ } else if (limit !== undefined && lastLineRead < totalFileLines) {
130
+ const remaining = totalFileLines - lastLineRead;
131
+ result += `\n\n[${remaining} more lines in file. Use s=${nextOffset} to continue.]`;
132
+ }
133
+
134
+ return { content: result, truncated, nextOffset, linesRead: selectedLines.length };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Suggestions
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export async function suggestSimilarFiles(
142
+ inputPath: string,
143
+ cwd: string,
144
+ ): Promise<string[]> {
145
+ const resolved = path.resolve(cwd, inputPath);
146
+ const dir = path.dirname(resolved);
147
+ const target = path.basename(resolved);
148
+
149
+ try {
150
+ const entries = await fs.readdir(dir, { withFileTypes: true });
151
+ const candidates: { name: string; dist: number }[] = [];
152
+
153
+ for (const entry of entries) {
154
+ const name = entry.name;
155
+ // Skip hidden files and node_modules
156
+ if (name.startsWith(".") || name === "node_modules") continue;
157
+
158
+ const dist = levenshtein(target.toLowerCase(), name.toLowerCase());
159
+ const maxLen = Math.max(target.length, name.length);
160
+ // Only suggest if reasonably similar (within 40% edit distance, or shares prefix)
161
+ if (dist <= Math.ceil(maxLen * 0.4) || name.startsWith(target.slice(0, 3))) {
162
+ candidates.push({ name: entry.isDirectory() ? name + "/" : name, dist });
163
+ }
164
+ }
165
+
166
+ return candidates
167
+ .sort((a, b) => a.dist - b.dist)
168
+ .slice(0, 3)
169
+ .map((c) => path.join(path.relative(cwd, dir), c.name));
170
+ } catch {
171
+ return [];
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Error hints
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function getErrorHint(error: string): string {
180
+ if (error.includes("File not found") || error.includes("file not found"))
181
+ return "Verify the path exists.";
182
+ if (error.includes("Could not find"))
183
+ return "Re-read the file first, then retry with exact f (oldText).";
184
+ if (error.includes("occurrences"))
185
+ return "Add more surrounding context to make oldText unique.";
186
+ if (error.includes("overlap"))
187
+ return "Merge overlapping edits into one.";
188
+ if (error.includes("No changes"))
189
+ return "File already has this content. No edit needed.";
190
+ if (error.includes("is not readable") || error.includes("not readable"))
191
+ return "Check file permissions.";
192
+ if (error.includes("ENOENT") || error.includes("no such file"))
193
+ return "Verify the path exists.";
194
+ if (error.includes("is beyond end of file"))
195
+ return "Use a smaller offset within the file length.";
196
+ return "";
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Main execute function
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export async function executeOperations(
204
+ operations: FileOpInput[],
205
+ cwd: string,
206
+ signal?: AbortSignal,
207
+ options: ExecuteOptions = {},
208
+ ): Promise<{ summary: string; contentText: string; results: OpResult[] }> {
209
+ const results: OpResult[] = [];
210
+ let failed = false;
211
+
212
+ const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
213
+ const errors: { path: string; op: string; message: string; hint?: string }[] = [];
214
+ const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
215
+ const includeLimitWarnings = options.includeLimitWarnings ?? true;
216
+
217
+ for (const op of operations) {
218
+ if (signal?.aborted) {
219
+ results.push({ op: op.o, path: op.p, status: "skipped", error: "Operation aborted." });
220
+ counts.skipped++;
221
+ continue;
222
+ }
223
+
224
+ if (failed) {
225
+ results.push({ op: op.o, path: op.p, status: "skipped" });
226
+ counts.skipped++;
227
+ continue;
228
+ }
229
+
230
+ try {
231
+ const resolvedPath = await validatePath(op.p, cwd);
232
+
233
+ switch (op.o) {
234
+ case "read": {
235
+ // Access check before reading
236
+ try {
237
+ await fs.access(resolvedPath);
238
+ } catch {
239
+ throw new Error(`File not found: ${op.p}`);
240
+ }
241
+ try {
242
+ await fs.access(resolvedPath, fs.constants.R_OK);
243
+ } catch {
244
+ throw new Error(`File not readable: ${op.p}`);
245
+ }
246
+
247
+ const rawContent = await fs.readFile(resolvedPath, "utf-8");
248
+ const { text } = stripBom(rawContent);
249
+ const allLines = text.split("\n");
250
+ const totalFileLines = allLines.length;
251
+
252
+ if (isBatchRead(options) && isFullFileRead(op, totalFileLines) && totalFileLines > SAFE_FULL_READ_LIMIT) {
253
+ const context = buildFileContextMap(op.p, allLines);
254
+ results.push({
255
+ op: "read",
256
+ path: op.p,
257
+ status: "ok",
258
+ totalLines: totalFileLines,
259
+ contextMap: true,
260
+ language: context.language !== "plain" ? context.language : undefined,
261
+ symbols: context.symbols.length > 0 ? context.symbols : undefined,
262
+ symbolsTruncated: context.symbolsTruncated,
263
+ });
264
+ counts.read++;
265
+ break;
266
+ }
267
+
268
+ let effectiveLimit = op.l;
269
+ let safetyTruncated = false;
270
+ let safetyWarning: string | undefined;
271
+ if (isBatchRead(options) && !isFullFileRead(op, totalFileLines)) {
272
+ if (effectiveLimit === undefined || effectiveLimit > TARGETED_READ_LINE_LIMIT) {
273
+ effectiveLimit = TARGETED_READ_LINE_LIMIT;
274
+ safetyTruncated = true;
275
+ safetyWarning = buildBatchReadSafetyWarning();
276
+ }
277
+ }
278
+
279
+ const { content: readContent, truncated, nextOffset, linesRead } =
280
+ readWithOffsetLimit(text, op.s, effectiveLimit, op.p, options.readOptions);
281
+ const finalContent = safetyWarning
282
+ ? `${readContent}\n\n${safetyWarning}`
283
+ : readContent;
284
+ const finalTruncated = truncated || safetyTruncated;
285
+
286
+ if (finalTruncated || (includeLimitWarnings && effectiveLimit !== undefined && (op.s ?? 1) - 1 + effectiveLimit < totalFileLines)) {
287
+ const shownLines = finalTruncated ? linesRead : effectiveLimit!;
288
+ truncatedFiles.push({
289
+ path: op.p,
290
+ shown: shownLines,
291
+ total: totalFileLines,
292
+ nextOffset,
293
+ });
294
+ }
295
+
296
+ results.push({
297
+ op: "read",
298
+ path: op.p,
299
+ status: "ok",
300
+ content: finalContent,
301
+ totalLines: totalFileLines,
302
+ warning: safetyWarning,
303
+ truncated: finalTruncated || undefined,
304
+ nextOffset,
305
+ });
306
+ counts.read++;
307
+ break;
308
+ }
309
+
310
+ case "write": {
311
+ if (!op.c && op.c !== "") {
312
+ throw new Error("c (content) is required for write operations.");
313
+ }
314
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
315
+ await fs.writeFile(resolvedPath, op.c!, "utf-8");
316
+ results.push({
317
+ op: "write",
318
+ path: op.p,
319
+ status: "ok",
320
+ bytes: Buffer.byteLength(op.c!, "utf-8"),
321
+ });
322
+ counts.write++;
323
+ break;
324
+ }
325
+
326
+ case "edit": {
327
+ if (!op.e || op.e.length === 0) {
328
+ throw new Error("e (edits) array is required for edit operations.");
329
+ }
330
+
331
+ const rawContent = await fs.readFile(resolvedPath, "utf-8");
332
+ const { bom, text: contentWithoutBom } = stripBom(rawContent);
333
+ const originalEnding = detectLineEnding(contentWithoutBom);
334
+ const normalizedContent = normalizeToLF(contentWithoutBom);
335
+
336
+ const { newContent, blocksChanged } = applyEdits(
337
+ normalizedContent,
338
+ op.e,
339
+ op.p,
340
+ );
341
+
342
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
343
+ await fs.writeFile(resolvedPath, finalContent, "utf-8");
344
+
345
+ results.push({
346
+ op: "edit",
347
+ path: op.p,
348
+ status: "ok",
349
+ blocksChanged,
350
+ });
351
+ counts.edit++;
352
+ break;
353
+ }
354
+
355
+ case "delete": {
356
+ let stat;
357
+ try {
358
+ stat = await fs.lstat(resolvedPath);
359
+ } catch (err: any) {
360
+ if (err.code === "ENOENT") {
361
+ throw new Error(`File not found: ${op.p}`);
362
+ }
363
+ throw err;
364
+ }
365
+ if (stat.isDirectory()) {
366
+ throw new Error(`Cannot delete directory: ${op.p}. Use a recursive removal tool or delete files individually.`);
367
+ }
368
+ await fs.unlink(resolvedPath);
369
+ results.push({ op: "delete", path: op.p, status: "ok" });
370
+ counts.delete++;
371
+ break;
372
+ }
373
+
374
+ default:
375
+ throw new Error(`Unknown operation type: ${op.o}`);
376
+ }
377
+ } catch (err) {
378
+ failed = true;
379
+ counts.error++;
380
+ const message = err instanceof Error ? err.message : String(err);
381
+
382
+ // Enrich file-not-found errors with fuzzy filename suggestions
383
+ let hint = getErrorHint(message);
384
+ if (
385
+ message.includes("File not found") ||
386
+ message.includes("file not found") ||
387
+ message.includes("ENOENT") ||
388
+ message.includes("no such file")
389
+ ) {
390
+ const suggestions = await suggestSimilarFiles(op.p, cwd);
391
+ if (suggestions.length > 0) {
392
+ hint += ` Did you mean: ${suggestions.join(", ")}?`;
393
+ }
394
+ }
395
+
396
+ errors.push({ path: op.p, op: op.o, message, hint });
397
+ results.push({
398
+ op: op.o,
399
+ path: op.p,
400
+ status: "error",
401
+ error: message,
402
+ hint,
403
+ });
404
+ }
405
+ }
406
+ // Build the enhanced summary and content text
407
+ const summary = buildSummary(counts, errors, truncatedFiles);
408
+ const contentText = buildContentText(summary, results);
409
+
410
+ return { summary, contentText, results };
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Summary / content rendering
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function buildSummary(
418
+ counts: { read: number; write: number; edit: number; delete: number; error: number; skipped: number },
419
+ errors: { path: string; op: string; message: string; hint?: string }[],
420
+ truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[],
421
+ ): string {
422
+ const totalSuccess =
423
+ counts.read + counts.write + counts.edit + counts.delete;
424
+ const totalOps = totalSuccess + counts.error + counts.skipped;
425
+
426
+ const parts: string[] = [];
427
+
428
+ // Build the success breakdown
429
+ const successParts: string[] = [];
430
+ if (counts.read > 0)
431
+ successParts.push(
432
+ `${counts.read} read${counts.read > 1 ? "s" : ""}`,
433
+ );
434
+ if (counts.write > 0)
435
+ successParts.push(
436
+ `${counts.write} write${counts.write > 1 ? "s" : ""}`,
437
+ );
438
+ if (counts.edit > 0)
439
+ successParts.push(
440
+ `${counts.edit} edit${counts.edit > 1 ? "s" : ""}`,
441
+ );
442
+ if (counts.delete > 0)
443
+ successParts.push(
444
+ `${counts.delete} delete${counts.delete > 1 ? "s" : ""}`,
445
+ );
446
+
447
+ if (counts.error === 0) {
448
+ // All success
449
+ parts.push(`✓ ${totalOps} operations: ${successParts.join(", ")}`);
450
+ } else {
451
+ // Mixed success/failure
452
+ parts.push(
453
+ `✗ ${counts.error} failed${counts.skipped > 0 ? `, ${counts.skipped} skipped` : ""}`,
454
+ );
455
+ if (totalSuccess > 0) {
456
+ parts.push(` ✓ ${successParts.join(", ")} ok`);
457
+ }
458
+ for (const err of errors) {
459
+ const hint = err.hint ?? "";
460
+ const hintSuffix = hint ? ` — ${hint}` : "";
461
+ parts.push(` ✗ ${err.op} ${err.path}: ${err.message}${hintSuffix}`);
462
+ }
463
+ }
464
+
465
+ // Truncation warnings
466
+ for (const tf of truncatedFiles) {
467
+ if (tf.nextOffset) {
468
+ parts.push(
469
+ ` ⚠ ${tf.path} truncated (${tf.shown}/${tf.total} lines) — use s=${tf.nextOffset}`,
470
+ );
471
+ }
472
+ }
473
+
474
+ return parts.join("\n");
475
+ }
476
+
477
+ export function buildContextMapText(result: OpResult): string {
478
+ const title = result.language || result.symbols ? "context map" : "file summary";
479
+ const lines: string[] = [`\n--- ${result.path} ${title} ---`];
480
+ lines.push(`Total lines: ${result.totalLines ?? 0}`);
481
+ if (result.language) lines.push(`Language: ${result.language}`);
482
+ lines.push("");
483
+ lines.push(`Full-file content omitted because file exceeds SAFE_FULL_READ_LIMIT=${SAFE_FULL_READ_LIMIT} lines.`);
484
+ lines.push("Use targeted reads with s/l, for example:");
485
+ lines.push(`{ "o": "read", "p": "${result.path}", "s": <startLine>, "l": <lineCount> }`);
486
+
487
+ if (result.symbols && result.symbols.length > 0) {
488
+ lines.push("");
489
+ lines.push("Context map:");
490
+ for (const entry of result.symbols) {
491
+ lines.push(`- ${entry.kind} ${entry.name} ${entry.startLine}-${entry.endLine}`);
492
+ }
493
+ if (result.symbolsTruncated) {
494
+ lines.push(`... [Context map truncated. Over ${100} entries detected. Use targeted reads to explore further.]`);
495
+ }
496
+ } else if (result.language) {
497
+ lines.push("");
498
+ lines.push("No context map entries detected for this structured file.");
499
+ }
500
+
501
+ return lines.join("\n");
502
+ }
503
+
504
+ function buildContentText(summary: string, results: OpResult[]): string {
505
+ const sections: string[] = [summary];
506
+
507
+ for (const r of results) {
508
+ if (r.op === "read" && r.status === "ok" && r.contextMap) {
509
+ sections.push(buildContextMapText(r));
510
+ } else if (r.op === "read" && r.status === "ok" && r.content) {
511
+ const lineInfo = r.totalLines !== undefined ? ` (${r.totalLines} lines)` : "";
512
+ sections.push(`\n--- ${r.path}${lineInfo} ---\n${r.content}`);
513
+ } else if (r.op === "edit" && r.status === "ok") {
514
+ const blockInfo = r.blocksChanged !== undefined ? `${r.blocksChanged} block${r.blocksChanged > 1 ? "s" : ""}` : "";
515
+ sections.push(`\n--- edit: ${r.path} (${blockInfo}) ---`);
516
+ } else if (r.op === "write" && r.status === "ok") {
517
+ sections.push(`\n--- write: ${r.path} (${r.bytes ?? 0} bytes) ---`);
518
+ } else if (r.op === "delete" && r.status === "ok") {
519
+ sections.push(`\n--- delete: ${r.path} ---`);
520
+ } else if (r.status === "error") {
521
+ sections.push(`\n--- ${r.op}: ${r.path} ---\nError: ${r.error}`);
522
+ }
523
+ }
524
+
525
+ return sections.join("");
526
+ }