pi-hashline-edit-pro 0.15.5 → 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.5",
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
  }
@@ -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:
@@ -168,6 +169,7 @@ export async function execPipeline(
168
169
  );
169
170
 
170
171
  const absolutePath = toCwd(path, cwd);
172
+ const resolvedPath = await resolveTarget(absolutePath);
171
173
  const resolved = resEdits(toolEdits);
172
174
  const anchorResult = applyEdits(
173
175
  originalNormalized,
@@ -199,7 +201,7 @@ export async function execPipeline(
199
201
  }
200
202
 
201
203
  // Compute stable result hashes using hash-aware preservation
202
- const resultHashes = await lineHashes(result, absolutePath, {
204
+ const resultHashes = await lineHashes(result, resolvedPath, {
203
205
  content: originalNormalized,
204
206
  hashes: originalHashes,
205
207
  removedHashes,
@@ -437,6 +439,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
437
439
  }],
438
440
  })
439
441
  : normReq(params);
442
+ const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
440
443
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };
441
444
  const path = normalizedParams.path;
442
445
  const absolutePath = toCwd(path, ctx.cwd);
@@ -469,6 +472,10 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
469
472
  ? normalizedParams.changes.length
470
473
  : 0;
471
474
 
475
+ if (normWarnings) {
476
+ warnings.push(...normWarnings);
477
+ }
478
+
472
479
  if (originalNormalized === result) {
473
480
  const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
474
481
  return buildNoop({
@@ -494,6 +501,12 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
494
501
  absolutePath,
495
502
  bom + restoreEndings(result, originalEnding),
496
503
  );
504
+ saveUndo(mutationTargetPath, {
505
+ content: originalNormalized,
506
+ bom,
507
+ originalEnding,
508
+ hashes: originalHashes,
509
+ });
497
510
  const updatedSnapshotId = (await fileSnap(absolutePath))
498
511
  .snapshotId;
499
512
 
@@ -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
+ }