pi-agent-flow 1.2.4 → 1.2.6
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 +3 -0
- package/agents/scout.md +3 -0
- package/package.json +1 -1
- package/src/agents.ts +12 -1
- package/src/ambient.d.ts +1 -0
- package/src/batch/constants.ts +102 -0
- package/src/batch/execute.ts +526 -0
- package/src/batch/fuzzy-edit.ts +323 -0
- package/src/batch/index.ts +364 -0
- package/src/batch/render.ts +74 -0
- package/src/batch/symbols.ts +341 -0
- package/src/batch.ts +13 -1647
- package/src/cli-args.ts +11 -0
- package/src/config.ts +10 -0
- package/src/executor.ts +399 -0
- package/src/flow.ts +12 -11
- package/src/hooks.ts +112 -2
- package/src/index.ts +170 -229
- package/src/types.ts +42 -0
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
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
|
+
}
|