pi-hashline-edit-pro 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import { join, isAbsolute } from "path";
4
4
  import { initHasher } from "./src/hashline";
5
5
  import { regReplace } from "./src/replace";
6
6
  import { regReplaceFlat } from "./src/replace-flat";
7
+ import { regReplaceUndo } from "./src/replace-undo";
7
8
  import { regRead, fmtReadPreview } from "./src/read";
8
9
  import { toLF, stripBOM } from "./src/replace-diff";
9
10
  import { visLines } from "./src/utils";
@@ -22,7 +23,7 @@ export default function (pi: ExtensionAPI): void {
22
23
  // Register the bulk-mode replace tool by default. The session_start handler
23
24
  // will re-register with the correct mode from the persisted config.
24
25
  regReplace(pi);
25
-
26
+ regReplaceUndo(pi);
26
27
  const debugValue = process.env.PI_HASHLINE_DEBUG;
27
28
  // Initial auto-read from env var; session_start overrides with persisted value
28
29
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -94,9 +94,7 @@ Rules:
94
94
  - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
95
95
  - All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
96
96
  - If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
97
- - The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
98
- On success, the response text is empty (or contains only warnings if present). {{AUTO_READ_GUIDANCE}}
99
-
97
+ On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
100
98
  ⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
101
99
 
102
100
  Wrong: To replace lines 2-3 in this function:
@@ -129,3 +127,5 @@ Error recovery:
129
127
  - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
130
128
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
131
129
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
130
+
131
+ **Undo:** If a replace produced incorrect results, call `last_replace_undo` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
@@ -79,7 +79,8 @@ Rules:
79
79
  - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
80
80
  - If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
81
81
  - The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
82
- On success, the response text is empty (or contains only warnings if present). {{AUTO_READ_GUIDANCE}}
82
+ - `hash_range_inclusive` and `content_lines` must be native JSON values, not JSON strings. Do not serialize them pass them as a proper array and array of strings respectively.
83
+ On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
83
84
 
84
85
  ⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
85
86
 
@@ -112,3 +113,5 @@ Error recovery:
112
113
  - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
113
114
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
114
115
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
116
+
117
+ **Undo:** If a replace produced incorrect results, call `last_replace_undo` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
@@ -142,7 +142,8 @@ Rules:
142
142
  - All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
143
143
  - If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
144
144
  - The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
145
- On success, the response text is empty (or contains only warnings if present). {{AUTO_READ_GUIDANCE}}
145
+ - `changes` (in bulk mode), `hash_range_inclusive`, and `content_lines` must be native JSON values, not JSON strings. Do not serialize them — pass them as proper arrays and strings.
146
+ On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
146
147
 
147
148
  ⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
148
149
 
@@ -176,3 +177,5 @@ Error recovery:
176
177
  - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
177
178
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
178
179
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
180
+
181
+ **Undo:** If a replace produced incorrect results, call `last_replace_undo` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
@@ -1,58 +1,60 @@
1
1
  import { constants } from "fs";
2
2
  import { lineHashes } from "./hashline";
3
3
  import { loadFileKindAndText, type LFile } from "./file-kind";
4
+ import { resolveTarget } from "./fs-write";
4
5
  import { toCwd } from "./path-utils";
5
6
  import { detectEnding, toLF, stripBOM } from "./replace-diff";
6
7
  import { abortIf } from "./runtime";
7
8
  import { assertText, valAccess } from "./validation";
8
9
 
9
10
  export interface NormFile {
10
- absolutePath: string;
11
- normalized: string;
12
- bom: string;
13
- originalEnding: "\r\n" | "\n";
14
- fileHashes: string[];
15
- hadUtf8DecodeErrors: boolean;
11
+ absolutePath: string;
12
+ normalized: string;
13
+ bom: string;
14
+ originalEnding: "\r\n" | "\n";
15
+ fileHashes: string[];
16
+ hadUtf8DecodeErrors: boolean;
16
17
  }
17
18
 
18
19
  export async function readNormFile(
19
- path: string,
20
- cwd: string,
21
- signal: AbortSignal | undefined,
22
- accessMode: number = constants.R_OK,
23
- preloadedFile?: LFile,
24
- maxLines?: number,
20
+ path: string,
21
+ cwd: string,
22
+ signal: AbortSignal | undefined,
23
+ accessMode: number = constants.R_OK,
24
+ preloadedFile?: LFile,
25
+ maxLines?: number,
25
26
  ): Promise<NormFile> {
26
- const absolutePath = toCwd(path, cwd);
27
+ const absolutePath = toCwd(path, cwd);
28
+ const resolvedPath = await resolveTarget(absolutePath);
27
29
 
28
- abortIf(signal);
29
- await valAccess(absolutePath, path, accessMode);
30
+ abortIf(signal);
31
+ await valAccess(resolvedPath, path, accessMode);
30
32
 
31
- abortIf(signal);
32
- const file = preloadedFile ?? (await loadFileKindAndText(absolutePath));
33
- assertText(file, path);
33
+ abortIf(signal);
34
+ const file = preloadedFile ?? (await loadFileKindAndText(resolvedPath));
35
+ assertText(file, path);
34
36
 
35
- abortIf(signal);
36
- const { bom, text: rawContent } = stripBOM(file.text);
37
- const originalEnding = detectEnding(rawContent);
38
- const normalized = toLF(rawContent);
37
+ abortIf(signal);
38
+ const { bom, text: rawContent } = stripBOM(file.text);
39
+ const originalEnding = detectEnding(rawContent);
40
+ const normalized = toLF(rawContent);
39
41
 
40
- if (maxLines !== undefined) {
41
- const lineCount = normalized.split("\n").length;
42
- if (lineCount > maxLines) {
43
- throw new Error(
44
- `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
45
- );
46
- }
47
- }
42
+ if (maxLines !== undefined) {
43
+ const lineCount = normalized.split("\n").length;
44
+ if (lineCount > maxLines) {
45
+ throw new Error(
46
+ `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
47
+ );
48
+ }
49
+ }
48
50
 
49
- const fileHashes = await lineHashes(normalized, absolutePath);
50
- return {
51
- absolutePath,
52
- normalized,
53
- bom,
54
- originalEnding,
55
- fileHashes,
56
- hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
57
- };
51
+ const fileHashes = await lineHashes(normalized, resolvedPath);
52
+ return {
53
+ absolutePath: resolvedPath,
54
+ normalized,
55
+ bom,
56
+ originalEnding,
57
+ fileHashes,
58
+ hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
59
+ };
58
60
  }
@@ -1,313 +1,17 @@
1
- import { Markdown, Text } from "@earendil-works/pi-tui";
2
1
  import type {
3
2
  ExtensionAPI,
4
- ToolDefinition,
5
3
  } from "@earendil-works/pi-coding-agent";
6
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
- import { Type } from "typebox";
8
- import { constants } from "fs";
9
4
  import {
10
- genDiff,
11
- restoreEndings,
12
- } from "./replace-diff";
13
- import { readNormFile } from "./file-reader";
14
- import { normReq } from "./replace-normalize";
15
- import { isRec } from "./utils";
16
- import { MAX_HASH_LINES } from "./constants";
17
- import { resolveTarget, writeAtomic } from "./fs-write";
18
- import {
19
- applyEdits,
20
- resEdits,
21
- type HTEdit,
22
- } from "./hashline";
23
- import { toCwd } from "./path-utils";
24
- import { abortIf } from "./runtime";
25
- import { fileSnap } from "./snapshot";
26
- import {
27
- buildChanged,
28
- buildNoop,
29
- type RMeta,
30
- type RMetrics,
31
- } from "./replace-response";
32
- import {
33
- buildAppliedText,
34
- mkMdTheme,
35
- fmtCall,
36
- fmtResultMd,
37
- getPreviewInput,
38
- getResultText,
39
- isApplied,
40
- type RPreview,
41
- type RRState,
42
- } from "./replace-render";
43
- import { loadP, loadGuide } from "./prompts";
44
- import { execPipeline, compPreview, type ReplaceDetails } from "./replace";
45
- import { readAutoReadSync } from "./config";
46
-
47
- const contentLinesSchema = Type.Array(Type.String(), {
48
- description:
49
- "literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
50
- });
51
-
52
- const hashRangeInclSchema = Type.Array(
53
- Type.String({ description: "anchor (3-char HASH)" }),
54
- {
55
- description: "inclusive hash range to replace [start_hash, end_hash]. Each element must be the 3-character hash anchor only; do not include the │ separator or line content.",
56
- minItems: 2,
57
- maxItems: 2,
58
- },
59
- );
60
-
61
- /**
62
- * Flat-mode schema: hash_range_inclusive and content_lines are at the top
63
- * level instead of inside a "changes" array. Only a single edit is supported
64
- * per call (no bulk changes).
65
- */
66
- export const flatEditToolSchema = Type.Object(
67
- {
68
- path: Type.String({ description: "path" }),
69
- hash_range_inclusive: hashRangeInclSchema,
70
- content_lines: contentLinesSchema,
71
- },
72
- { additionalProperties: false },
73
- );
74
-
75
- type ToolDef = ToolDefinition<
76
- typeof flatEditToolSchema,
77
- ReplaceDetails,
78
- RRState
79
- > & { renderShell?: "default" | "self" };
80
-
81
- function reuseText(context: any, content: string): Text {
82
- const t = context.lastComponent instanceof Text
83
- ? context.lastComponent
84
- : new Text("", 0, 0);
85
- t.setText(content);
86
- return t;
87
- }
88
-
89
- function reuseMarkdown(context: any, content: string, theme: any): Markdown {
90
- const m = context.lastComponent instanceof Markdown
91
- ? context.lastComponent
92
- : new Markdown("", 0, 0, mkMdTheme(theme));
93
- m.setText(content);
94
- return m;
95
- }
96
-
97
- export function buildToolDef(): ToolDef {
98
- const autoRead = readAutoReadSync();
99
- const readGuidance = autoRead
100
- ? "Anchors are provided automatically after write operations when auto-read is enabled."
101
- : "Call `read` to get fresh anchors for follow-up edits.";
102
-
103
- const E_DESC = loadP("../prompts/replace-flat.md", {
104
- AUTO_READ_GUIDANCE: readGuidance,
105
- });
106
- const E_SNIPPET = loadP("../prompts/replace-flat-snippet.md");
107
- const E_GUIDE = loadGuide("../prompts/replace-flat-guidelines.md", {
108
- AUTO_READ_GUIDANCE: readGuidance,
109
- });
110
-
111
- return {
112
- name: "replace",
113
- label: "Replace",
114
- description: E_DESC,
115
- parameters: flatEditToolSchema,
116
- promptSnippet: E_SNIPPET,
117
- promptGuidelines: E_GUIDE,
118
- prepareArguments: (args: unknown) => {
119
- // Minimal normalization: file_path → path, JSON string parsing.
120
- // The flat-to-canonical conversion happens in execute().
121
- if (!isRec(args)) return args as any;
122
- const record = { ...args };
123
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
124
- record.path = record.file_path;
125
- delete record.file_path;
126
- }
127
- if (typeof record.hash_range_inclusive === "string") {
128
- try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
129
- }
130
- if (typeof record.content_lines === "string") {
131
- try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
132
- }
133
- return record as any;
134
- },
135
- renderShell: "default",
136
- renderCall(args, theme, context) {
137
- const previewInput = getPreviewInput(args);
138
- if (context.executionStarted) {
139
- context.state.argsKey = undefined;
140
- context.state.preview = undefined;
141
- context.state.previewGeneration =
142
- (context.state.previewGeneration ?? 0) + 1;
143
- } else if (!context.argsComplete || !previewInput) {
144
- context.state.argsKey = undefined;
145
- context.state.preview = undefined;
146
- context.state.previewGeneration =
147
- (context.state.previewGeneration ?? 0) + 1;
148
- } else {
149
- const argsKey = JSON.stringify(previewInput);
150
- if (context.state.argsKey !== argsKey) {
151
- context.state.argsKey = argsKey;
152
- context.state.preview = undefined;
153
- const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
154
- context.state.previewGeneration = previewGeneration;
155
- compPreview(previewInput, context.cwd)
156
- .then((preview) => {
157
- if (
158
- context.state.argsKey === argsKey &&
159
- context.state.previewGeneration === previewGeneration
160
- ) {
161
- context.state.preview = preview;
162
- context.invalidate();
163
- }
164
- })
165
- .catch((err: unknown) => {
166
- if (
167
- context.state.argsKey === argsKey &&
168
- context.state.previewGeneration === previewGeneration
169
- ) {
170
- context.state.preview = {
171
- error: err instanceof Error ? err.message : String(err),
172
- };
173
- context.invalidate();
174
- }
175
- });
176
- }
177
- }
178
- const text =
179
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
180
- text.setText(
181
- fmtCall(
182
- getPreviewInput(args) ?? undefined,
183
- context.state as RRState,
184
- context.expanded,
185
- theme,
186
- ),
187
- );
188
- return text;
189
- },
190
-
191
- renderResult(result, { isPartial }, theme, context) {
192
- if (isPartial) {
193
- return reuseText(context, theme.fg("warning", "Editing..."));
194
- }
195
-
196
- const typedResult = result as {
197
- content?: Array<{ type: string; text?: string }>;
198
- details?: ReplaceDetails;
199
- };
200
- const renderedText = getResultText(typedResult);
201
-
202
- const renderState = context.state as RRState | undefined;
203
- if (renderState) {
204
- renderState.preview = undefined;
205
- renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
206
- }
207
-
208
- if (context.isError) {
209
- return renderedText
210
- ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
211
- : new Text("", 0, 0);
212
- }
213
-
214
- if (isApplied(typedResult.details)) {
215
- const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
216
- return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
217
- }
218
-
219
- if (!renderedText) return new Text("", 0, 0);
220
- return reuseMarkdown(context, fmtResultMd(renderedText), theme);
221
- },
222
-
223
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
224
- // Wrap flat params into canonical shape for the pipeline
225
- const canonical = normReq({
226
- path: params.path,
227
- changes: [{
228
- hash_range_inclusive: params.hash_range_inclusive,
229
- content_lines: params.content_lines,
230
- }],
231
- });
232
- const normalizedParams = canonical as { path: string; changes: HTEdit[] };
233
- const path = normalizedParams.path;
234
- const absolutePath = toCwd(path, ctx.cwd);
235
- const mutationTargetPath = await resolveTarget(absolutePath);
236
- return withFileMutationQueue(mutationTargetPath, async () => {
237
- abortIf(signal);
238
-
239
- const {
240
- originalNormalized,
241
- originalHashes,
242
- result,
243
- bom,
244
- originalEnding,
245
- hadUtf8DecodeErrors,
246
- warnings,
247
- noopEdits,
248
- firstChangedLine,
249
- lastChangedLine,
250
- resultHashes,
251
- } = await execPipeline(
252
- normalizedParams,
253
- ctx.cwd,
254
- constants.R_OK | constants.W_OK,
255
- signal,
256
- );
257
-
258
- const editsAttempted = 1; // flat mode: exactly one edit per call
259
-
260
- if (originalNormalized === result) {
261
- const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
262
- return buildNoop({
263
- path,
264
- noopEdits,
265
- snapshotId: noopSnapshotId,
266
- editMeta: {
267
- editsAttempted,
268
- noopEditsCount: noopEdits?.length ?? 0,
269
- },
270
- warnings,
271
- });
272
- }
273
-
274
- if (hadUtf8DecodeErrors) {
275
- warnings.push(
276
- "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
277
- );
278
- }
279
-
280
- abortIf(signal);
281
- await writeAtomic(
282
- absolutePath,
283
- bom + restoreEndings(result, originalEnding),
284
- );
285
- const updatedSnapshotId = (await fileSnap(absolutePath))
286
- .snapshotId;
5
+ buildToolDef,
6
+ flatEditToolSchema,
7
+ } from "./replace";
287
8
 
288
- const editMeta: RMeta = {
289
- editsAttempted,
290
- noopEditsCount: noopEdits?.length ?? 0,
291
- firstChangedLine,
292
- lastChangedLine,
293
- };
9
+ export { flatEditToolSchema };
294
10
 
295
- const successInput = {
296
- path,
297
- originalNormalized,
298
- originalHashes,
299
- result,
300
- resultHashes,
301
- warnings,
302
- snapshotId: updatedSnapshotId,
303
- editMeta,
304
- };
305
- return buildChanged(successInput);
306
- });
307
- },
308
- };
11
+ export function buildToolDefFlat() {
12
+ return buildToolDef({ flat: true });
309
13
  }
310
14
 
311
15
  export function regReplaceFlat(pi: ExtensionAPI): void {
312
- pi.registerTool(buildToolDef());
16
+ pi.registerTool(buildToolDef({ flat: true }));
313
17
  }
@@ -13,34 +13,56 @@ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | und
13
13
  * Coerces an array of edit items: JSON-string items → objects,
14
14
  * JSON-string content_lines → string arrays. Shared by the `changes`
15
15
  * and `edits` normalization branches.
16
+ * Returns a warning if any item was a JSON string.
16
17
  */
17
- function coerceEditArray(items: unknown[]): unknown[] {
18
- return items
19
- .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
18
+ function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
19
+ const warnings: string[] = [];
20
+ const result = items
21
+ .map((item: unknown) => {
22
+ if (typeof item === "string") {
23
+ const parsed = tryParseJSON(item, isRec);
24
+ if (parsed) {
25
+ warnings.push("Edit item was passed as a JSON string instead of a native object. Use native JSON values, not serialized strings.");
26
+ return parsed;
27
+ }
28
+ }
29
+ return item;
30
+ })
20
31
  .map((change: unknown) => {
21
32
  if (!isRec(change)) return change;
22
33
  if (typeof change.content_lines !== "string") return change;
23
34
  const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
24
35
  Array.isArray(v) && v.every((i) => typeof i === "string"),
25
36
  );
26
- return parsed ? { ...change, content_lines: parsed } : change;
37
+ if (parsed) {
38
+ warnings.push("content_lines was passed as a JSON string inside an edit item. Use a native array of strings.");
39
+ return { ...change, content_lines: parsed };
40
+ }
41
+ return change;
27
42
  });
43
+ return { result, warnings };
28
44
  }
29
45
 
30
46
  /**
31
47
  * Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
32
48
  * single objects → wrapped in array. Shared by the `changes` and `edits`
33
49
  * normalization branches.
50
+ * Returns a warning if the field was a JSON string.
34
51
  */
35
52
  function normalizeField(
36
53
  record: Record<string, unknown>,
37
54
  from: string,
38
55
  to: string,
39
- ): void {
40
- if (!has(record, from)) return;
56
+ ): string | undefined {
57
+ if (!has(record, from)) return undefined;
41
58
  const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
59
+ const wasString = typeof record[from] === "string" && raw !== record[from];
42
60
  if (Array.isArray(raw)) {
43
- record[to] = coerceEditArray(raw);
61
+ const { result, warnings } = coerceEditArray(raw);
62
+ record[to] = result;
63
+ if (warnings.length > 0) {
64
+ return warnings.join(" ");
65
+ }
44
66
  } else {
45
67
  const single =
46
68
  typeof raw === "string"
@@ -48,9 +70,19 @@ function normalizeField(
48
70
  : isRec(raw)
49
71
  ? raw
50
72
  : undefined;
51
- if (single) record[to] = coerceEditArray([single]);
73
+ if (single) {
74
+ const { result, warnings } = coerceEditArray([single]);
75
+ record[to] = result;
76
+ if (warnings.length > 0) {
77
+ return warnings.join(" ");
78
+ }
79
+ }
52
80
  }
53
81
  if (from !== to) delete record[from];
82
+ if (wasString) {
83
+ return `Field "${from}" was passed as a JSON string instead of a native array. Use native JSON values, not serialized strings.`;
84
+ }
85
+ return undefined;
54
86
  }
55
87
 
56
88
  export function normReq(input: unknown): unknown {
@@ -59,25 +91,36 @@ export function normReq(input: unknown): unknown {
59
91
  }
60
92
 
61
93
  const record: Record<string, unknown> = { ...input };
94
+ const warnings: string[] = [];
62
95
 
63
96
  if (typeof record.path !== "string" && typeof record.file_path === "string") {
64
97
  record.path = record.file_path;
65
98
  delete record.file_path;
66
99
  }
67
100
 
68
- normalizeField(record, "changes", "changes");
69
- normalizeField(record, "edits", "changes");
101
+ const w1 = normalizeField(record, "changes", "changes");
102
+ if (w1) warnings.push(w1);
103
+ const w2 = normalizeField(record, "edits", "changes");
104
+ if (w2) warnings.push(w2);
70
105
 
71
106
  // Handle flat format: hash_range_inclusive and content_lines at top level
72
107
  // (no changes array). Wrap them into a single-element changes array.
73
108
  if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
74
- const hri = tryParseJSON(record.hash_range_inclusive, (v): v is string[] =>
109
+ const hriRaw = record.hash_range_inclusive;
110
+ const hri = tryParseJSON(hriRaw, (v): v is string[] =>
75
111
  Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
76
- ) ?? record.hash_range_inclusive;
112
+ ) ?? hriRaw;
113
+ if (typeof hriRaw === "string" && hri !== hriRaw) {
114
+ warnings.push("hash_range_inclusive was passed as a JSON string instead of a native array. Use native JSON values.");
115
+ }
77
116
 
78
- const cl = tryParseJSON(record.content_lines, (v): v is string[] =>
117
+ const clRaw = record.content_lines;
118
+ const cl = tryParseJSON(clRaw, (v): v is string[] =>
79
119
  Array.isArray(v) && v.every((i) => typeof i === "string")
80
- ) ?? record.content_lines;
120
+ ) ?? clRaw;
121
+ if (typeof clRaw === "string" && cl !== clRaw) {
122
+ warnings.push("content_lines was passed as a JSON string instead of a native array. Use native JSON values.");
123
+ }
81
124
 
82
125
  if (Array.isArray(hri) && Array.isArray(cl)) {
83
126
  record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
@@ -86,6 +129,9 @@ export function normReq(input: unknown): unknown {
86
129
  }
87
130
  }
88
131
 
132
+ if (warnings.length > 0) {
133
+ (record as Record<string, unknown>)._normWarnings = warnings;
134
+ }
135
+
89
136
  return record;
90
137
  }
91
-
@@ -148,11 +148,14 @@ export function buildChanged(input: SuccessInput): TResult {
148
148
  const removedLines = cntDiff(diffResult.diff, "-");
149
149
  const warningsBlock = warnBlock(warnings);
150
150
  const successPrefix = `Successfully replaced in ${path}.`;
151
+ const lineSummary = addedLines > 0 || removedLines > 0
152
+ ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
153
+ : "";
151
154
  const text = resultLines.length === 0
152
155
  ? "File is empty. Use replace to insert content."
153
156
  : warningsBlock
154
- ? `${successPrefix}${warningsBlock}`
155
- : successPrefix;
157
+ ? `${successPrefix}${lineSummary}${warningsBlock}`
158
+ : `${successPrefix}${lineSummary}`;
156
159
 
157
160
  const metrics = buildM({
158
161
  classification: "applied",
@@ -0,0 +1,122 @@
1
+ import { readFile } from "fs/promises";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { getUndo, clearUndo } from "./undo-store";
6
+ import { loadHashStore, saveHashStore } from "./hash-store";
7
+ import { resolveTarget, writeAtomic } from "./fs-write";
8
+ import { toCwd } from "./path-utils";
9
+ import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
10
+
11
+ function cntDiff(diff: string, marker: "+" | "-"): number {
12
+ if (!diff) return 0;
13
+ let count = 0;
14
+ for (const line of diff.split("\n")) {
15
+ if (
16
+ line.startsWith(marker) &&
17
+ !line.startsWith(`${marker}${marker}${marker}`)
18
+ ) {
19
+ count += 1;
20
+ }
21
+ }
22
+ return count;
23
+ }
24
+
25
+ export function regReplaceUndo(pi: ExtensionAPI): void {
26
+ pi.registerTool({
27
+ name: "last_replace_undo",
28
+ label: "Undo Last Replace",
29
+ description:
30
+ "Undo the last replace operation on a file, reverting it to its previous state. " +
31
+ "Use this when a replace produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). " +
32
+ "After undoing, call `read` to get fresh anchors for a corrected replace.",
33
+ parameters: Type.Object({
34
+ path: Type.String({
35
+ description: "Path to the file to undo the last replace on",
36
+ }),
37
+ }),
38
+
39
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
40
+ const path = params.path;
41
+ const absolutePath = toCwd(path, ctx.cwd);
42
+ const mutationTargetPath = await resolveTarget(absolutePath);
43
+
44
+ const undo = getUndo(mutationTargetPath);
45
+ if (!undo) {
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text: `No undo history for ${path}. There is no previous replace to revert.`,
51
+ },
52
+ ],
53
+ isError: true,
54
+ details: {},
55
+ };
56
+ }
57
+
58
+ return withFileMutationQueue(mutationTargetPath, async () => {
59
+ // Read current file content to compute diff against the pre-replace state
60
+ let currentNormalized = "";
61
+ try {
62
+ const currentRaw = await readFile(mutationTargetPath, "utf-8");
63
+ const { text: currentStripped } = stripBOM(currentRaw);
64
+ currentNormalized = toLF(currentStripped);
65
+ } catch {
66
+ // File may have been deleted; treat as empty
67
+ currentNormalized = "";
68
+ }
69
+
70
+ // Compute diff: old = pre-replace (undo.content), new = current
71
+ const diffResult = genDiff(undo.content, currentNormalized, 0);
72
+ const linesAddedByReplace = cntDiff(diffResult.diff, "+");
73
+ const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
74
+
75
+ // Write the pre-edit content back to the file
76
+ await writeAtomic(
77
+ mutationTargetPath,
78
+ undo.bom + restoreEndings(undo.content, undo.originalEnding),
79
+ );
80
+
81
+ // Restore the hash store snapshot so the next read returns the same hashes
82
+ const store = await loadHashStore();
83
+ store.snapshots[mutationTargetPath] = {
84
+ content: undo.content,
85
+ hashes: undo.hashes,
86
+ };
87
+ await saveHashStore(store);
88
+
89
+ // Clear undo so a second call without an intervening replace is a no-op
90
+ clearUndo(mutationTargetPath);
91
+
92
+ const parts: string[] = [
93
+ `Undone last replace on ${path}.`,
94
+ ];
95
+ if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
96
+ parts.push(
97
+ `Removed ${linesAddedByReplace} line(s) that were added and restored ${linesRemovedByReplace} line(s) that were removed.`,
98
+ );
99
+ }
100
+ parts.push(
101
+ "File reverted to previous state. Call `read` to get fresh anchors for follow-up edits.",
102
+ );
103
+
104
+ return {
105
+ content: [
106
+ {
107
+ type: "text",
108
+ text: parts.join("\n"),
109
+ },
110
+ ],
111
+ details: {
112
+ metrics: {
113
+ added_lines: linesRemovedByReplace,
114
+ removed_lines: linesAddedByReplace,
115
+ classification: "applied" as const,
116
+ },
117
+ },
118
+ };
119
+ });
120
+ },
121
+ });
122
+ }
package/src/replace.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  } from "./replace-render";
45
45
  import { loadP, loadGuide } from "./prompts";
46
46
  import { readAutoReadSync } from "./config";
47
+ import { saveUndo } from "./undo-store";
47
48
 
48
49
  const contentLinesSchema = Type.Array(Type.String(), {
49
50
  description:
@@ -75,6 +76,20 @@ export const editToolSchema = Type.Object(
75
76
  { additionalProperties: false },
76
77
  );
77
78
 
79
+ /**
80
+ * Flat-mode schema: hash_range_inclusive and content_lines are at the top
81
+ * level instead of inside a "changes" array. Only a single edit is supported
82
+ * per call (no bulk changes).
83
+ */
84
+ export const flatEditToolSchema = Type.Object(
85
+ {
86
+ path: Type.String({ description: "path" }),
87
+ hash_range_inclusive: hashRangeInclSchema,
88
+ content_lines: contentLinesSchema,
89
+ },
90
+ { additionalProperties: false },
91
+ );
92
+
78
93
  export type ReqParams = {
79
94
  path: string;
80
95
  changes: HTEdit[];
@@ -154,6 +169,7 @@ export async function execPipeline(
154
169
  );
155
170
 
156
171
  const absolutePath = toCwd(path, cwd);
172
+ const resolvedPath = await resolveTarget(absolutePath);
157
173
  const resolved = resEdits(toolEdits);
158
174
  const anchorResult = applyEdits(
159
175
  originalNormalized,
@@ -165,15 +181,27 @@ export async function execPipeline(
165
181
 
166
182
  const result = anchorResult.content;
167
183
 
168
- // Collect hashes targeted by the edit for hash-aware diff disambiguation
184
+ // Collect hashes targeted by the edit for hash-aware diff disambiguation.
185
+ // Include every hash in the replaced range, not just the boundary anchors,
186
+ // so that a surviving line whose content is identical to an interior line
187
+ // of the edit is not matched to a removed hash.
188
+ // Use originalHashes.indexOf to find line numbers since resEdits returns
189
+ // HEdit[] (Anchor = { hash }) not RHEdit[] (RAnchor = { line, hash }).
169
190
  const removedHashes = new Set<string>();
170
191
  for (const edit of resolved) {
171
- removedHashes.add(edit.hash_range_inclusive[0].hash);
172
- removedHashes.add(edit.hash_range_inclusive[1].hash);
192
+ const startHash = edit.hash_range_inclusive[0].hash;
193
+ const endHash = edit.hash_range_inclusive[1].hash;
194
+ const startLine = originalHashes.indexOf(startHash);
195
+ const endLine = originalHashes.indexOf(endHash);
196
+ if (startLine >= 0 && endLine >= 0) {
197
+ for (let i = startLine; i <= endLine; i++) {
198
+ removedHashes.add(originalHashes[i]!);
199
+ }
200
+ }
173
201
  }
174
202
 
175
203
  // Compute stable result hashes using hash-aware preservation
176
- const resultHashes = await lineHashes(result, absolutePath, {
204
+ const resultHashes = await lineHashes(result, resolvedPath, {
177
205
  content: originalNormalized,
178
206
  hashes: originalHashes,
179
207
  removedHashes,
@@ -247,12 +275,12 @@ export async function compPreview(
247
275
  }
248
276
 
249
277
  type ToolDef = ToolDefinition<
250
- typeof editToolSchema,
278
+ any,
251
279
  ReplaceDetails,
252
280
  RRState
253
281
  > & { renderShell?: "default" | "self" };
254
282
 
255
- function reuseText(context: any, content: string): Text {
283
+ export function reuseText(context: any, content: string): Text {
256
284
  const t = context.lastComponent instanceof Text
257
285
  ? context.lastComponent
258
286
  : new Text("", 0, 0);
@@ -260,7 +288,7 @@ function reuseText(context: any, content: string): Text {
260
288
  return t;
261
289
  }
262
290
 
263
- function reuseMarkdown(context: any, content: string, theme: any): Markdown {
291
+ export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
264
292
  const m = context.lastComponent instanceof Markdown
265
293
  ? context.lastComponent
266
294
  : new Markdown("", 0, 0, mkMdTheme(theme));
@@ -268,29 +296,49 @@ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
268
296
  return m;
269
297
  }
270
298
 
271
- export function buildToolDef(): ToolDef {
299
+ export function buildToolDef(opts: { flat: boolean }): ToolDef {
272
300
  const autoRead = readAutoReadSync();
273
301
  const readGuidance = autoRead
274
302
  ? "Anchors are provided automatically after write operations when auto-read is enabled."
275
303
  : "Call `read` to get fresh anchors for follow-up edits.";
276
304
 
277
- const E_DESC = loadP("../prompts/replace-bulk.md", {
305
+ const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
278
306
  AUTO_READ_GUIDANCE: readGuidance,
279
307
  });
280
- const E_SNIPPET = loadP("../prompts/replace-bulk-snippet.md");
281
- const E_GUIDE = loadGuide("../prompts/replace-bulk-guidelines.md", {
308
+ const E_SNIPPET = loadP(opts.flat ? "../prompts/replace-flat-snippet.md" : "../prompts/replace-bulk-snippet.md");
309
+ const E_GUIDE = loadGuide(opts.flat ? "../prompts/replace-flat-guidelines.md" : "../prompts/replace-bulk-guidelines.md", {
282
310
  AUTO_READ_GUIDANCE: readGuidance,
283
311
  });
284
312
 
313
+ const parameters = opts.flat ? flatEditToolSchema : editToolSchema;
314
+
285
315
  return {
286
316
  name: "replace",
287
317
  label: "Replace",
288
318
  description: E_DESC,
289
- parameters: editToolSchema,
319
+ parameters,
290
320
  promptSnippet: E_SNIPPET,
291
321
  promptGuidelines: E_GUIDE,
292
- prepareArguments: (args: unknown) =>
293
- normReq(args) as ReqParams,
322
+ prepareArguments: opts.flat
323
+ ? (args: unknown) => {
324
+ // Minimal normalization: file_path → path, JSON string parsing.
325
+ // The flat-to-canonical conversion happens in execute().
326
+ if (!isRec(args)) return args as any;
327
+ const record = { ...args };
328
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
329
+ record.path = record.file_path;
330
+ delete record.file_path;
331
+ }
332
+ if (typeof record.hash_range_inclusive === "string") {
333
+ try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
334
+ }
335
+ if (typeof record.content_lines === "string") {
336
+ try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
337
+ }
338
+ return record as any;
339
+ }
340
+ : (args: unknown) =>
341
+ normReq(args) as ReqParams,
294
342
  renderShell: "default",
295
343
  renderCall(args, theme, context) {
296
344
  const previewInput = getPreviewInput(args);
@@ -380,9 +428,19 @@ export function buildToolDef(): ToolDef {
380
428
  },
381
429
 
382
430
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
383
- const normalized = normReq(params);
384
- assertReq(normalized);
385
- const normalizedParams = normalized;
431
+ // Flat mode: wrap top-level fields into a single-element changes array.
432
+ // Bulk mode: use params as-is after normReq normalization.
433
+ const canonical = opts.flat
434
+ ? normReq({
435
+ path: (params as any).path,
436
+ changes: [{
437
+ hash_range_inclusive: (params as any).hash_range_inclusive,
438
+ content_lines: (params as any).content_lines,
439
+ }],
440
+ })
441
+ : normReq(params);
442
+ const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
443
+ const normalizedParams = canonical as { path: string; changes: HTEdit[] };
386
444
  const path = normalizedParams.path;
387
445
  const absolutePath = toCwd(path, ctx.cwd);
388
446
  const mutationTargetPath = await resolveTarget(absolutePath);
@@ -408,9 +466,15 @@ export function buildToolDef(): ToolDef {
408
466
  signal,
409
467
  );
410
468
 
411
- const editsAttempted = Array.isArray(normalizedParams.changes)
412
- ? normalizedParams.changes.length
413
- : 0;
469
+ const editsAttempted = opts.flat
470
+ ? 1
471
+ : Array.isArray(normalizedParams.changes)
472
+ ? normalizedParams.changes.length
473
+ : 0;
474
+
475
+ if (normWarnings) {
476
+ warnings.push(...normWarnings);
477
+ }
414
478
 
415
479
  if (originalNormalized === result) {
416
480
  const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
@@ -437,6 +501,12 @@ export function buildToolDef(): ToolDef {
437
501
  absolutePath,
438
502
  bom + restoreEndings(result, originalEnding),
439
503
  );
504
+ saveUndo(mutationTargetPath, {
505
+ content: originalNormalized,
506
+ bom,
507
+ originalEnding,
508
+ hashes: originalHashes,
509
+ });
440
510
  const updatedSnapshotId = (await fileSnap(absolutePath))
441
511
  .snapshotId;
442
512
 
@@ -464,5 +534,5 @@ export function buildToolDef(): ToolDef {
464
534
  }
465
535
 
466
536
  export function regReplace(pi: ExtensionAPI): void {
467
- pi.registerTool(buildToolDef());
537
+ pi.registerTool(buildToolDef({ flat: false }));
468
538
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * In-memory undo store for the last_replace_undo tool.
3
+ *
4
+ * Before each successful replace, the pre-edit file state (normalized content,
5
+ * BOM, original line ending, and hashes) is saved keyed by absolute path.
6
+ * The undo tool reads this entry, restores the file, and clears the entry.
7
+ *
8
+ * Only the most recent replace per file is tracked — calling undo twice
9
+ * without an intervening replace will produce "no undo history".
10
+ */
11
+
12
+ export interface UndoEntry {
13
+ /** Normalized (LF) content before the edit */
14
+ content: string;
15
+ /** BOM prefix that was stripped from the original file */
16
+ bom: string;
17
+ /** Original line ending detected before the edit */
18
+ originalEnding: "\r\n" | "\n";
19
+ /** Hash array for the pre-edit content */
20
+ hashes: string[];
21
+ }
22
+
23
+ const undoMap = new Map<string, UndoEntry>();
24
+
25
+ export function saveUndo(path: string, entry: UndoEntry): void {
26
+ undoMap.set(path, entry);
27
+ }
28
+
29
+ export function getUndo(path: string): UndoEntry | undefined {
30
+ return undoMap.get(path);
31
+ }
32
+
33
+ export function clearUndo(path: string): void {
34
+ undoMap.delete(path);
35
+ }