pi-hashline-edit-pro 0.15.5 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,11 +99,11 @@ Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro
99
99
  The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
100
100
 
101
101
  ### Chained edits
102
- After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present). To get fresh anchors for follow-up edits, call `read` on the file first. This avoids token overhead from re-displaying content the model already knows.
102
+ After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present). When auto-read is enabled, fresh anchors are appended automatically. Otherwise call `read` to get fresh anchors for follow-up edits.
103
103
 
104
- ### Auto-read after write
104
+ ### Auto-read after write and replace
105
105
 
106
- Auto-read is **disabled by default**. When enabled, after a successful `write` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the newly written file without requiring a separate `read` call. The workflow becomes:
106
+ Auto-read is **disabled by default**. When enabled, after a successful `write` or `replace` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the file without requiring a separate `read` call. The workflow becomes:
107
107
 
108
108
  1. `write` a file, result includes hashline anchors
109
109
  2. `replace` using those anchors directly
@@ -121,7 +121,7 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
121
121
  | Command | Description |
122
122
  | --- | --- |
123
123
  | `/toggle-replace-mode` | Switch between bulk mode (`changes` array) and flat mode (top-level fields). Persists across sessions. |
124
- | `/toggle-auto-read` | Toggle automatic hashline anchors after write operations. Persists across sessions. |
124
+ | `/toggle-auto-read` | Toggle automatic hashline anchors after write and replace operations. Persists across sessions. |
125
125
 
126
126
  ### Config file
127
127
 
@@ -171,7 +171,7 @@ npm test
171
171
 
172
172
  Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
173
173
 
174
- Set `PI_HASHLINE_AUTO_READ=1` to enable auto-read after write by default on first run (can still be toggled at runtime with `/toggle-auto-read`; the setting persists across sessions once toggled).
174
+ Set `PI_HASHLINE_AUTO_READ=1` to enable auto-read after write and replace by default on first run (can still be toggled at runtime with `/toggle-auto-read`; the setting persists across sessions once toggled).
175
175
 
176
176
  ## Credits
177
177
 
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";
@@ -19,12 +20,9 @@ import { loadHashStore, pruneHashStore } from "./src/hash-store";
19
20
  export default function (pi: ExtensionAPI): void {
20
21
  regRead(pi);
21
22
 
22
- // Register the bulk-mode replace tool by default. The session_start handler
23
- // will re-register with the correct mode from the persisted config.
24
23
  regReplace(pi);
25
-
24
+ regReplaceUndo(pi);
26
25
  const debugValue = process.env.PI_HASHLINE_DEBUG;
27
- // Initial auto-read from env var; session_start overrides with persisted value
28
26
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
29
27
  let autoRead = autoReadValue === "1" || autoReadValue === "true";
30
28
 
@@ -32,12 +30,10 @@ export default function (pi: ExtensionAPI): void {
32
30
  const active = pi.getActiveTools();
33
31
  pi.setActiveTools(active.filter((t) => t !== "edit"));
34
32
  await initHasher();
35
- // Prune stale snapshots (files that no longer exist) on session start
36
33
  try {
37
34
  const store = await loadHashStore();
38
35
  await pruneHashStore(store);
39
- } catch { /* best-effort */ }
40
- // Re-register the replace tool according to the persisted mode
36
+ } catch {}
41
37
  const mode = await readReplaceMode();
42
38
  if (mode === "flat") {
43
39
  regReplaceFlat(pi);
@@ -45,7 +41,6 @@ export default function (pi: ExtensionAPI): void {
45
41
  regReplace(pi);
46
42
  }
47
43
 
48
- // Read the persisted auto-read setting (overrides env var default)
49
44
  autoRead = await readAutoRead();
50
45
 
51
46
  if (debugValue === "1" || debugValue === "true") {
@@ -57,7 +52,6 @@ export default function (pi: ExtensionAPI): void {
57
52
  description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
58
53
  handler: async (_args, ctx) => {
59
54
  const mode = await toggleReplaceMode();
60
- // Re-register the tool with the new mode
61
55
  if (mode === "flat") {
62
56
  regReplaceFlat(pi);
63
57
  } else {
@@ -68,10 +62,9 @@ export default function (pi: ExtensionAPI): void {
68
62
  });
69
63
 
70
64
  pi.registerCommand("toggle-auto-read", {
71
- description: "Toggle automatic hashline anchors after write operations",
65
+ description: "Toggle automatic hashline anchors after write and replace operations",
72
66
  handler: async (_args, ctx) => {
73
67
  autoRead = await toggleAutoRead();
74
- // Re-register the tool so prompts reflect the new auto-read setting
75
68
  const mode = await readReplaceMode();
76
69
  if (mode === "flat") {
77
70
  regReplaceFlat(pi);
@@ -79,13 +72,14 @@ export default function (pi: ExtensionAPI): void {
79
72
  regReplace(pi);
80
73
  }
81
74
  const state = autoRead ? "enabled" : "disabled";
82
- ctx.ui.notify(`Auto-read after write: ${state}`, "info");
75
+ ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
83
76
  },
84
77
  });
85
78
 
86
79
  pi.on("tool_result", async (event, ctx) => {
87
80
  if (!autoRead) return;
88
- if (event.toolName !== "write" || event.isError) return;
81
+ if (event.isError) return;
82
+ if (event.toolName !== "write" && event.toolName !== "replace") return;
89
83
 
90
84
  const filePath = (event.input as Record<string, unknown>)?.path;
91
85
  if (typeof filePath !== "string") return;
@@ -107,7 +101,7 @@ export default function (pi: ExtensionAPI): void {
107
101
  ],
108
102
  };
109
103
  } catch (error) {
110
- console.error("Auto-read after write failed:", error);
104
+ console.error("Auto-read after write/replace failed:", error);
111
105
  }
112
106
  });
113
107
  }
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.1",
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": {
@@ -1,5 +1,6 @@
1
1
  - Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
2
- - After a successful `replace`, the response text is empty (warnings only). {{AUTO_READ_GUIDANCE}}
2
+ - After a successful `replace`, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
3
3
  - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
4
  - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
5
- - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
5
+ - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
+ - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
@@ -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 `undo_last_replace` 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,5 +1,6 @@
1
1
  - Use `replace` with HASH anchors for all file changes. Only one edit per call (flat mode — no `changes` array).
2
- - After a successful `replace`, the response text is empty (warnings only). {{AUTO_READ_GUIDANCE}}
2
+ - After a successful `replace`, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
3
3
  - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
4
  - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
5
- - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
5
+ - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
+ - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
@@ -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 `undo_last_replace` 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,5 +1,7 @@
1
1
  - Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
2
- - After a successful `replace`, the response text is empty (warnings only). {{AUTO_READ_GUIDANCE}}
2
+ - After a successful `replace`, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
3
3
  - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
4
  - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
5
- - Two modes are available: bulk (default, uses `changes` array) and flat (top-level `hash_range_inclusive`/`content_lines`). Toggle with `/toggle-replace-mode`.
5
+ - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs copy them into `content_lines` unchanged.
6
+ - Two modes are available: bulk (default, uses `changes` array) and flat (top-level `hash_range_inclusive`/`content_lines`). Toggle with `/toggle-replace-mode`.
7
+ - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
@@ -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 `undo_last_replace` 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.
@@ -0,0 +1,3 @@
1
+ - Call `undo_last_replace` with the file path to revert the last replace on that file.
2
+ - After undoing, call `read` to get fresh anchors for a corrected replace.
3
+ - Only the most recent replace per file is tracked — calling undo twice without an intervening replace will produce "no undo history".
@@ -0,0 +1 @@
1
+ Undo the last replace on a file
@@ -0,0 +1 @@
1
+ Undo the last replace operation on a file, reverting it to its previous state. Use this when a replace produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). After undoing, call `read` to get fresh anchors for a corrected replace.
package/src/config.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { readFileSync } from "fs";
2
- import { homedir } from "os";
3
- import { join } from "path";
4
2
  import { readFile, writeFile, mkdir } from "fs/promises";
3
+ import { configDir, configPath } from "./paths";
5
4
 
6
5
  export type ReplaceMode = "bulk" | "flat";
7
6
 
@@ -12,18 +11,9 @@ export interface Config {
12
11
 
13
12
  const DEFAULT_CONFIG: Config = {
14
13
  replaceMode: "bulk",
15
- autoRead: false,
14
+ autoRead: false
16
15
  };
17
16
 
18
- /** Compute config path lazily so tests can override HOME before calling. */
19
- function configPath(): string {
20
- return join(homedir(), ".config", "pi-hashline-edit-pro", "config.json");
21
- }
22
-
23
- function configDir(): string {
24
- return join(homedir(), ".config", "pi-hashline-edit-pro");
25
- }
26
-
27
17
  export async function readConfig(): Promise<Config> {
28
18
  try {
29
19
  const content = await readFile(configPath(), "utf-8");
package/src/constants.ts CHANGED
@@ -2,14 +2,4 @@ export const AUTO_READ_MAX = 2000;
2
2
  export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
4
 
5
- /**
6
- * Hard ceiling on the number of lines the edit/hash path will process. Guards
7
- * `lineHashes` (which holds a per-line array plus a Set of every hash) and the
8
- * host diff against pathological inputs such as a multi-tens-of-MB generated
9
- * bundle or data dump. Only the `replace` path enforces it (`readNormFile` is
10
- * called with this limit only from `replace.ts`); `read` keeps its
11
- * truncate-and-preview behavior. ~1M lines is well above any realistic source
12
- * file, so this only fires on genuine pathologies. Tunable: lower it to bound
13
- * hashing cost more tightly.
14
- */
15
5
  export const MAX_HASH_LINES = 1_000_000;
@@ -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
  }
package/src/fs-write.ts CHANGED
@@ -101,14 +101,14 @@ export async function writeAtomic(
101
101
  }
102
102
  } catch (error: unknown) {
103
103
  await tempHandle.close();
104
- try { await rm(tempPath, { force: true }); } catch { /* best-effort */ }
104
+ try { await rm(tempPath, { force: true }); } catch {}
105
105
  throw error;
106
106
  }
107
107
  try {
108
108
  await tempHandle.close();
109
109
  await rename(tempPath, targetPath);
110
110
  } catch (error: unknown) {
111
- try { await rm(tempPath, { force: true }); } catch { /* best-effort */ }
111
+ try { await rm(tempPath, { force: true }); } catch {}
112
112
  throw error;
113
113
  }
114
114
  }
package/src/hash-store.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { readFile, writeFile, mkdir } from "fs/promises";
2
- import { homedir } from "os";
3
- import { join, dirname } from "path";
4
2
  import { stat } from "fs/promises";
3
+ import { hashStorePath, hashStoreDir } from "./paths";
5
4
 
6
5
  export interface FileSnapshot {
7
6
  content: string;
@@ -13,36 +12,28 @@ export interface HashStore {
13
12
  snapshots: Record<string, FileSnapshot>;
14
13
  }
15
14
 
16
- function storePath(): string {
17
- return join(homedir(), ".config", "pi-hashline-edit-pro", "hash-store.json");
18
- }
19
-
20
- function storeDir(): string {
21
- return dirname(storePath());
22
- }
23
-
24
15
  export async function loadHashStore(): Promise<HashStore> {
25
16
  try {
26
- const content = await readFile(storePath(), "utf-8");
17
+ const content = await readFile(hashStorePath(), "utf-8");
27
18
  const parsed = JSON.parse(content) as Partial<HashStore>;
28
19
  return {
29
20
  version: 1,
30
21
  snapshots: parsed.snapshots ?? {},
31
22
  };
32
23
  } catch {
33
- await mkdir(storeDir(), { recursive: true });
24
+ await mkdir(hashStoreDir(), { recursive: true });
34
25
  const defaultStore: HashStore = {
35
26
  version: 1,
36
27
  snapshots: {},
37
28
  };
38
- await writeFile(storePath(), JSON.stringify(defaultStore), "utf-8");
29
+ await writeFile(hashStorePath(), JSON.stringify(defaultStore), "utf-8");
39
30
  return defaultStore;
40
31
  }
41
32
  }
42
33
 
43
34
  export async function saveHashStore(store: HashStore): Promise<void> {
44
- await mkdir(storeDir(), { recursive: true });
45
- await writeFile(storePath(), JSON.stringify(store, null, 2), "utf-8");
35
+ await mkdir(hashStoreDir(), { recursive: true });
36
+ await writeFile(hashStorePath(), JSON.stringify(store, null, 2), "utf-8");
46
37
  }
47
38
 
48
39
  export async function pruneHashStore(store: HashStore): Promise<void> {
@@ -212,14 +212,6 @@ function assemble(
212
212
  return result;
213
213
  }
214
214
 
215
- /**
216
- * Builds the boundary-duplication warning shown after an edit. A minimal header
217
- * is followed by a hashline-anchored window: 2 lines of context before the
218
- * duplicated pair, the pair itself, and 2 lines after. The window carries the
219
- * post-edit hashes the model needs to remove the duplicate in a follow-up
220
- * `replace` (no `read` round-trip required, since the hashes are current and
221
- * staleness is per-line). Rows are plain `HASH│content` — no annotations.
222
- */
223
215
  export function fmtBoundaryWarning(params: {
224
216
  kind: "trailing" | "leading";
225
217
  survivingContent: string;
@@ -232,10 +224,6 @@ export function fmtBoundaryWarning(params: {
232
224
  ? "Boundary duplication (trailing): the last replacement line duplicated the next line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there."
233
225
  : "Boundary duplication (leading): the first replacement line duplicated the previous line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there.";
234
226
 
235
- // Locate the adjacent duplicated pair (two identical neighboring lines). The
236
- // occurrence-based matchIndex usually lands inside the pair; when a file has
237
- // several identical lines we pick the pair nearest matchIndex so the window
238
- // frames the duplication this edit introduced, not an unrelated one.
239
227
  let pairStart = -1;
240
228
  let bestDist = Infinity;
241
229
  for (let i = 0; i < params.resultLines.length - 1; i++) {
@@ -284,12 +272,6 @@ export function applyEdits(
284
272
  lastChangedLine: undefined,
285
273
  };
286
274
 
287
- edits = edits.map((edit) =>
288
- edit.content_lines.length === 1 &&
289
- edit.content_lines[0] === ""
290
- ? { ...edit, content_lines: [] }
291
- : edit,
292
- );
293
275
 
294
276
  const lineIndex = buildIdx(content);
295
277
  const fileHashes = precomputedHashes ?? _lineHashesPure(content);
@@ -5,11 +5,6 @@ import { loadHashStore, saveHashStore } from "../hash-store";
5
5
  export const HASH_LEN = 3;
6
6
  export const ANCHOR_LEN = HASH_LEN;
7
7
 
8
- /**
9
- * The `│` (U+2502) delimiter between a hash anchor and its line content. This
10
- * is the wire-format separator in `HASH│content` rows. Kept as a constant so
11
- * every construction site resolves the same delimiter.
12
- */
13
8
  export const HASH_SEP = "│";
14
9
 
15
10
  const ALPH =
@@ -74,17 +69,6 @@ function canon(line: string): string {
74
69
  return line.replace(/\r/g, "").trimEnd();
75
70
  }
76
71
 
77
- /**
78
- * Pure hash computation — no I/O, no persistence. Used internally by the
79
- * stable path and as a fallback when no file path is available.
80
- * Each line is hashed with xxHash32 over its canonical form (trailing
81
- * whitespace and CR characters stripped). If the base hash collides with a
82
- * hash already assigned to an earlier line, a retry counter (`:R{retry}`)
83
- * is appended to the canonical content and the hash is recomputed until a
84
- * unique anchor is found. This perfect-hashing step guarantees every line
85
- * in a file receives a distinct anchor, even when multiple lines contain
86
- * identical text (e.g. repeated `}` or `import` statements).
87
- */
88
72
  export function _lineHashesPure(content: string): string[] {
89
73
  const lines = content.split("\n");
90
74
  const hashes = new Array<string>(lines.length);
@@ -103,24 +87,6 @@ export function _lineHashesPure(content: string): string[] {
103
87
  return hashes;
104
88
  }
105
89
 
106
- /**
107
- * Stable, persistent-aware hash computation.
108
- *
109
- * When `path` is provided, uses the persistent hash store to preserve
110
- * hashes for unchanged lines across edits. When `previous` is also
111
- * provided (called from the replace pipeline), diffs the previous content
112
- * against the new content and copies hashes for unchanged lines to their
113
- * new positions. New/changed lines allocate fresh hashes with collision
114
- * avoidance against the preserved set.
115
- *
116
- * When `path` is provided without `previous` (called from read), loads
117
- * the stored snapshot for that path. If the content matches, returns the
118
- * saved hashes. Otherwise computes fresh hashes via `_lineHashesPure` and
119
- * saves a new snapshot.
120
- *
121
- * When `path` is not provided, falls back to `_lineHashesPure` (for
122
- * backward compatibility and tests that don't need persistence).
123
- */
124
90
  export async function lineHashes(
125
91
  content: string,
126
92
  path?: string,
@@ -132,7 +98,6 @@ export async function lineHashes(
132
98
 
133
99
  const store = await loadHashStore();
134
100
 
135
- // Case 1: previous content provided (replace pipeline) — diff and preserve
136
101
  if (previous) {
137
102
  const newHashes = mapStableHashes(
138
103
  previous.content, previous.hashes,
@@ -144,28 +109,17 @@ export async function lineHashes(
144
109
  return newHashes;
145
110
  }
146
111
 
147
- // Case 2: no previous — check snapshot or compute fresh
148
112
  const snapshot = store.snapshots[path];
149
113
  if (snapshot && snapshot.content === content) {
150
114
  return snapshot.hashes;
151
115
  }
152
116
 
153
- // Compute fresh hashes
154
117
  const newHashes = _lineHashesPure(content);
155
118
  store.snapshots[path] = { content, hashes: newHashes };
156
119
  await saveHashStore(store);
157
120
  return newHashes;
158
121
  }
159
122
 
160
- /**
161
- * Maps old hashes to new positions using hash-aware content matching.
162
- * Unlike Diff.diffLines (which is content-only and cannot distinguish
163
- * identical lines at different positions), this algorithm uses the
164
- * removedHashes set to disambiguate: when a line appears multiple times
165
- * in the old content and one occurrence was targeted by the edit,
166
- * the surviving occurrence is matched to the non-removed hash.
167
- * New/changed lines allocate fresh hashes with collision avoidance.
168
- */
169
123
  function mapStableHashes(
170
124
  oldContent: string,
171
125
  oldHashes: string[],
@@ -176,8 +130,6 @@ function mapStableHashes(
176
130
  const newHashes = new Array<string>(newLines.length);
177
131
  const used = new Set<string>();
178
132
 
179
- // Build a map from line content to list of (index, hash) for the old content.
180
- // We process occurrences left-to-right so that matching preserves order.
181
133
  const contentMap = new Map<string, { index: number; hash: string }[]>();
182
134
  const oldLines = oldContent.split("\n");
183
135
  for (let i = 0; i < oldLines.length; i++) {
@@ -191,17 +143,11 @@ function mapStableHashes(
191
143
  }
192
144
  }
193
145
 
194
- // Match each new line to an old occurrence by content.
195
- // For lines with duplicate content, prefer occurrences whose hash
196
- // was NOT targeted by the edit (those are the survivors).
197
146
  for (let i = 0; i < newLines.length; i++) {
198
147
  const line = newLines[i]!;
199
148
  const candidates = contentMap.get(line);
200
149
  if (!candidates || candidates.length === 0) continue;
201
150
 
202
- // Find the best match: prefer a non-removed occurrence.
203
- // If all remaining occurrences are removed, use the first one anyway
204
- // (it will get a fresh hash in the fill step since its hash is in used+removed).
205
151
  let bestIdx = 0;
206
152
  if (removedHashes && removedHashes.size > 0) {
207
153
  for (let j = 0; j < candidates.length; j++) {
@@ -217,7 +163,6 @@ function mapStableHashes(
217
163
  used.add(match.hash);
218
164
  }
219
165
 
220
- // Fill remaining (new/changed) lines with fresh hashes
221
166
  for (let i = 0; i < newLines.length; i++) {
222
167
  if (newHashes[i]) continue;
223
168
  const c = canon(newLines[i]!);
@@ -230,7 +175,6 @@ function mapStableHashes(
230
175
  used.add(hash);
231
176
  newHashes[i] = hash;
232
177
  }
233
-
234
178
  return newHashes;
235
179
  }
236
180
 
package/src/paths.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { homedir } from "os";
2
+ import { join, dirname } from "path";
3
+
4
+
5
+ export function configDir(): string {
6
+ return join(homedir(), ".config", "pi-hashline-edit-pro");
7
+ }
8
+
9
+ export function configPath(): string {
10
+ return join(configDir(), "config.json");
11
+ }
12
+
13
+ export function hashStorePath(): string {
14
+ return join(configDir(), "hash-store.json");
15
+ }
16
+
17
+ export function hashStoreDir(): string {
18
+ return dirname(hashStorePath());
19
+ }
package/src/read.ts CHANGED
@@ -41,11 +41,6 @@ function normPosInt(
41
41
  return value;
42
42
  }
43
43
 
44
- /**
45
- * Builds the `[Showing lines A-B of N. Use offset=M to continue.]` hint shared by
46
- * `fmtReadPreview` and the auto-read-after-write handler. `byteLimit` adds the
47
- * `(size limit)` suffix used when truncation is byte-driven.
48
- */
49
44
  export function formatPaginationHint(
50
45
  startLine: number,
51
46
  endLine: number,
@@ -47,10 +47,6 @@ export function genDiff(
47
47
  newContentHashes?: string[],
48
48
  oldHashes?: string[],
49
49
  ): { diff: string; firstChangedLine: number | undefined } {
50
- // Run Diff.diffLines on raw content only (no hash annotations) so that
51
- // lines whose content is identical are never reported as changed even
52
- // when their hash differs due to collision resolution or position
53
- // tracking. Hashes are used purely for display via fmtDiffLine.
54
50
  const oldLines = oldContent.split("\n");
55
51
  const newLines = newContent.split("\n");
56
52
  const effectiveOldHashes = oldHashes ?? _lineHashesPure(oldContent);
@@ -9,38 +9,48 @@ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | und
9
9
  return undefined;
10
10
  }
11
11
 
12
- /**
13
- * Coerces an array of edit items: JSON-string items → objects,
14
- * JSON-string content_lines → string arrays. Shared by the `changes`
15
- * and `edits` normalization branches.
16
- */
17
- function coerceEditArray(items: unknown[]): unknown[] {
18
- return items
19
- .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
12
+ function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
13
+ const warnings: string[] = [];
14
+ const result = items
15
+ .map((item: unknown) => {
16
+ if (typeof item === "string") {
17
+ const parsed = tryParseJSON(item, isRec);
18
+ if (parsed) {
19
+ warnings.push("Edit item was passed as a JSON string instead of a native object. Use native JSON values, not serialized strings.");
20
+ return parsed;
21
+ }
22
+ }
23
+ return item;
24
+ })
20
25
  .map((change: unknown) => {
21
26
  if (!isRec(change)) return change;
22
27
  if (typeof change.content_lines !== "string") return change;
23
28
  const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
24
29
  Array.isArray(v) && v.every((i) => typeof i === "string"),
25
30
  );
26
- return parsed ? { ...change, content_lines: parsed } : change;
31
+ if (parsed) {
32
+ warnings.push("content_lines was passed as a JSON string inside an edit item. Use a native array of strings.");
33
+ return { ...change, content_lines: parsed };
34
+ }
35
+ return change;
27
36
  });
37
+ return { result, warnings };
28
38
  }
29
39
 
30
- /**
31
- * Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
32
- * single objects → wrapped in array. Shared by the `changes` and `edits`
33
- * normalization branches.
34
- */
35
40
  function normalizeField(
36
41
  record: Record<string, unknown>,
37
42
  from: string,
38
43
  to: string,
39
- ): void {
40
- if (!has(record, from)) return;
44
+ ): string | undefined {
45
+ if (!has(record, from)) return undefined;
41
46
  const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
47
+ const wasString = typeof record[from] === "string" && raw !== record[from];
42
48
  if (Array.isArray(raw)) {
43
- record[to] = coerceEditArray(raw);
49
+ const { result, warnings } = coerceEditArray(raw);
50
+ record[to] = result;
51
+ if (warnings.length > 0) {
52
+ return warnings.join(" ");
53
+ }
44
54
  } else {
45
55
  const single =
46
56
  typeof raw === "string"
@@ -48,9 +58,32 @@ function normalizeField(
48
58
  : isRec(raw)
49
59
  ? raw
50
60
  : undefined;
51
- if (single) record[to] = coerceEditArray([single]);
61
+ if (single) {
62
+ const { result, warnings } = coerceEditArray([single]);
63
+ record[to] = result;
64
+ if (warnings.length > 0) {
65
+ return warnings.join(" ");
66
+ }
67
+ }
52
68
  }
53
69
  if (from !== to) delete record[from];
70
+ if (wasString) {
71
+ return `Field "${from}" was passed as a JSON string instead of a native array. Use native JSON values, not serialized strings.`;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ export function normalizeFilePath(record: Record<string, unknown>): void {
77
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
78
+ record.path = record.file_path;
79
+ delete record.file_path;
80
+ }
81
+ }
82
+
83
+ export function tryParseField(record: Record<string, unknown>, field: string): void {
84
+ if (typeof record[field] === "string") {
85
+ try { record[field] = JSON.parse(record[field] as string); } catch {}
86
+ }
54
87
  }
55
88
 
56
89
  export function normReq(input: unknown): unknown {
@@ -59,25 +92,31 @@ export function normReq(input: unknown): unknown {
59
92
  }
60
93
 
61
94
  const record: Record<string, unknown> = { ...input };
95
+ const warnings: string[] = [];
62
96
 
63
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
64
- record.path = record.file_path;
65
- delete record.file_path;
66
- }
97
+ normalizeFilePath(record);
67
98
 
68
- normalizeField(record, "changes", "changes");
69
- normalizeField(record, "edits", "changes");
99
+ const w1 = normalizeField(record, "changes", "changes");
100
+ if (w1) warnings.push(w1);
101
+ const w2 = normalizeField(record, "edits", "changes");
102
+ if (w2) warnings.push(w2);
70
103
 
71
- // Handle flat format: hash_range_inclusive and content_lines at top level
72
- // (no changes array). Wrap them into a single-element changes array.
73
104
  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[] =>
105
+ const hriRaw = record.hash_range_inclusive;
106
+ const hri = tryParseJSON(hriRaw, (v): v is string[] =>
75
107
  Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
76
- ) ?? record.hash_range_inclusive;
108
+ ) ?? hriRaw;
109
+ if (typeof hriRaw === "string" && hri !== hriRaw) {
110
+ warnings.push("hash_range_inclusive was passed as a JSON string instead of a native array. Use native JSON values.");
111
+ }
77
112
 
78
- const cl = tryParseJSON(record.content_lines, (v): v is string[] =>
113
+ const clRaw = record.content_lines;
114
+ const cl = tryParseJSON(clRaw, (v): v is string[] =>
79
115
  Array.isArray(v) && v.every((i) => typeof i === "string")
80
- ) ?? record.content_lines;
116
+ ) ?? clRaw;
117
+ if (typeof clRaw === "string" && cl !== clRaw) {
118
+ warnings.push("content_lines was passed as a JSON string instead of a native array. Use native JSON values.");
119
+ }
81
120
 
82
121
  if (Array.isArray(hri) && Array.isArray(cl)) {
83
122
  record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
@@ -86,6 +125,9 @@ export function normReq(input: unknown): unknown {
86
125
  }
87
126
  }
88
127
 
128
+ if (warnings.length > 0) {
129
+ (record as Record<string, unknown>)._normWarnings = warnings;
130
+ }
131
+
89
132
  return record;
90
133
  }
91
-
@@ -1,6 +1,6 @@
1
1
  import type { ReplaceDetails } from "./replace";
2
2
  import { genDiff } from "./replace-diff";
3
- import { visLines } from "./utils";
3
+ import { visLines, cntDiff } from "./utils";
4
4
 
5
5
  type TResult = {
6
6
  content: Array<{ type: "text"; text: string }>;
@@ -50,19 +50,6 @@ export interface SuccessInput {
50
50
  editMeta: RMeta;
51
51
  }
52
52
 
53
- function cntDiff(diff: string, marker: "+" | "-"): number {
54
- if (!diff) return 0;
55
- let count = 0;
56
- for (const line of diff.split("\n")) {
57
- if (
58
- line.startsWith(marker) &&
59
- !line.startsWith(`${marker}${marker}${marker}`)
60
- ) {
61
- count += 1;
62
- }
63
- }
64
- return count;
65
- }
66
53
 
67
54
  function buildM(args: {
68
55
  classification: "applied" | "noop";
@@ -148,11 +135,14 @@ export function buildChanged(input: SuccessInput): TResult {
148
135
  const removedLines = cntDiff(diffResult.diff, "-");
149
136
  const warningsBlock = warnBlock(warnings);
150
137
  const successPrefix = `Successfully replaced in ${path}.`;
138
+ const lineSummary = addedLines > 0 || removedLines > 0
139
+ ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
140
+ : "";
151
141
  const text = resultLines.length === 0
152
142
  ? "File is empty. Use replace to insert content."
153
143
  : warningsBlock
154
- ? `${successPrefix}${warningsBlock}`
155
- : successPrefix;
144
+ ? `${successPrefix}${lineSummary}${warningsBlock}`
145
+ : `${successPrefix}${lineSummary}`;
156
146
 
157
147
  const metrics = buildM({
158
148
  classification: "applied",
@@ -0,0 +1,104 @@
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
+ import { cntDiff } from "./utils";
11
+ import { loadP, loadGuide } from "./prompts";
12
+
13
+
14
+ export function regReplaceUndo(pi: ExtensionAPI): void {
15
+ pi.registerTool({
16
+ name: "undo_last_replace",
17
+ label: "Undo Last Replace",
18
+ description: loadP("../prompts/undo-last-replace.md"),
19
+ promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
20
+ promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
21
+ parameters: Type.Object({
22
+ path: Type.String({
23
+ description: "Path to the file to undo the last replace on",
24
+ }),
25
+ }),
26
+
27
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
28
+ const path = params.path;
29
+ const absolutePath = toCwd(path, ctx.cwd);
30
+ const mutationTargetPath = await resolveTarget(absolutePath);
31
+
32
+ const undo = getUndo(mutationTargetPath);
33
+ if (!undo) {
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text",
38
+ text: `No undo history for ${path}. There is no previous replace to revert.`,
39
+ },
40
+ ],
41
+ isError: true,
42
+ details: {},
43
+ };
44
+ }
45
+
46
+ return withFileMutationQueue(mutationTargetPath, async () => {
47
+ let currentNormalized = "";
48
+ try {
49
+ const currentRaw = await readFile(mutationTargetPath, "utf-8");
50
+ const { text: currentStripped } = stripBOM(currentRaw);
51
+ currentNormalized = toLF(currentStripped);
52
+ } catch {
53
+ currentNormalized = "";
54
+ }
55
+
56
+ const diffResult = genDiff(undo.content, currentNormalized, 0);
57
+ const linesAddedByReplace = cntDiff(diffResult.diff, "+");
58
+ const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
59
+
60
+ await writeAtomic(
61
+ mutationTargetPath,
62
+ undo.bom + restoreEndings(undo.content, undo.originalEnding),
63
+ );
64
+
65
+ const store = await loadHashStore();
66
+ store.snapshots[mutationTargetPath] = {
67
+ content: undo.content,
68
+ hashes: undo.hashes,
69
+ };
70
+ await saveHashStore(store);
71
+
72
+ clearUndo(mutationTargetPath);
73
+
74
+ const parts: string[] = [
75
+ `Undone last replace on ${path}.`,
76
+ ];
77
+ if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
78
+ parts.push(
79
+ `Removed ${linesAddedByReplace} line(s) that were added and restored ${linesRemovedByReplace} line(s) that were removed.`,
80
+ );
81
+ }
82
+ parts.push(
83
+ "File reverted to previous state. Call `read` to get fresh anchors for follow-up edits.",
84
+ );
85
+
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: parts.join("\n"),
91
+ },
92
+ ],
93
+ details: {
94
+ metrics: {
95
+ added_lines: linesRemovedByReplace,
96
+ removed_lines: linesAddedByReplace,
97
+ classification: "applied" as const,
98
+ },
99
+ },
100
+ };
101
+ });
102
+ },
103
+ });
104
+ }
package/src/replace.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  restoreEndings,
12
12
  } from "./replace-diff";
13
13
  import { readNormFile } from "./file-reader";
14
- import { normReq } from "./replace-normalize";
14
+ import { normReq, normalizeFilePath, tryParseField } from "./replace-normalize";
15
15
  import { isRec, has, rejectUnknownFields } from "./utils";
16
16
  import { MAX_HASH_LINES } from "./constants";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
@@ -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,11 +76,6 @@ export const editToolSchema = Type.Object(
75
76
  { additionalProperties: false },
76
77
  );
77
78
 
78
- /**
79
- * Flat-mode schema: hash_range_inclusive and content_lines are at the top
80
- * level instead of inside a "changes" array. Only a single edit is supported
81
- * per call (no bulk changes).
82
- */
83
79
  export const flatEditToolSchema = Type.Object(
84
80
  {
85
81
  path: Type.String({ description: "path" }),
@@ -168,6 +164,7 @@ export async function execPipeline(
168
164
  );
169
165
 
170
166
  const absolutePath = toCwd(path, cwd);
167
+ const resolvedPath = await resolveTarget(absolutePath);
171
168
  const resolved = resEdits(toolEdits);
172
169
  const anchorResult = applyEdits(
173
170
  originalNormalized,
@@ -179,12 +176,6 @@ export async function execPipeline(
179
176
 
180
177
  const result = anchorResult.content;
181
178
 
182
- // Collect hashes targeted by the edit for hash-aware diff disambiguation.
183
- // Include every hash in the replaced range, not just the boundary anchors,
184
- // so that a surviving line whose content is identical to an interior line
185
- // of the edit is not matched to a removed hash.
186
- // Use originalHashes.indexOf to find line numbers since resEdits returns
187
- // HEdit[] (Anchor = { hash }) not RHEdit[] (RAnchor = { line, hash }).
188
179
  const removedHashes = new Set<string>();
189
180
  for (const edit of resolved) {
190
181
  const startHash = edit.hash_range_inclusive[0].hash;
@@ -198,14 +189,12 @@ export async function execPipeline(
198
189
  }
199
190
  }
200
191
 
201
- // Compute stable result hashes using hash-aware preservation
202
- const resultHashes = await lineHashes(result, absolutePath, {
192
+ const resultHashes = await lineHashes(result, resolvedPath, {
203
193
  content: originalNormalized,
204
194
  hashes: originalHashes,
205
195
  removedHashes,
206
196
  });
207
197
 
208
- // Format boundary warnings using stable result hashes
209
198
  const resultLines = result.split("\n");
210
199
  const warnings = [...(anchorResult.warnings ?? [])];
211
200
  for (const bw of anchorResult.boundaryWarnings ?? []) {
@@ -297,7 +286,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
297
286
  export function buildToolDef(opts: { flat: boolean }): ToolDef {
298
287
  const autoRead = readAutoReadSync();
299
288
  const readGuidance = autoRead
300
- ? "Anchors are provided automatically after write operations when auto-read is enabled."
289
+ ? "Anchors are provided automatically after write and replace operations when auto-read is enabled."
301
290
  : "Call `read` to get fresh anchors for follow-up edits.";
302
291
 
303
292
  const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
@@ -319,20 +308,11 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
319
308
  promptGuidelines: E_GUIDE,
320
309
  prepareArguments: opts.flat
321
310
  ? (args: unknown) => {
322
- // Minimal normalization: file_path → path, JSON string parsing.
323
- // The flat-to-canonical conversion happens in execute().
324
311
  if (!isRec(args)) return args as any;
325
312
  const record = { ...args };
326
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
327
- record.path = record.file_path;
328
- delete record.file_path;
329
- }
330
- if (typeof record.hash_range_inclusive === "string") {
331
- try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
332
- }
333
- if (typeof record.content_lines === "string") {
334
- try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
335
- }
313
+ normalizeFilePath(record);
314
+ tryParseField(record, "hash_range_inclusive");
315
+ tryParseField(record, "content_lines");
336
316
  return record as any;
337
317
  }
338
318
  : (args: unknown) =>
@@ -426,8 +406,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
426
406
  },
427
407
 
428
408
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
429
- // Flat mode: wrap top-level fields into a single-element changes array.
430
- // Bulk mode: use params as-is after normReq normalization.
431
409
  const canonical = opts.flat
432
410
  ? normReq({
433
411
  path: (params as any).path,
@@ -437,6 +415,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
437
415
  }],
438
416
  })
439
417
  : normReq(params);
418
+ const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
440
419
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };
441
420
  const path = normalizedParams.path;
442
421
  const absolutePath = toCwd(path, ctx.cwd);
@@ -469,6 +448,10 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
469
448
  ? normalizedParams.changes.length
470
449
  : 0;
471
450
 
451
+ if (normWarnings) {
452
+ warnings.push(...normWarnings);
453
+ }
454
+
472
455
  if (originalNormalized === result) {
473
456
  const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
474
457
  return buildNoop({
@@ -494,6 +477,12 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
494
477
  absolutePath,
495
478
  bom + restoreEndings(result, originalEnding),
496
479
  );
480
+ saveUndo(mutationTargetPath, {
481
+ content: originalNormalized,
482
+ bom,
483
+ originalEnding,
484
+ hashes: originalHashes,
485
+ });
497
486
  const updatedSnapshotId = (await fileSnap(absolutePath))
498
487
  .snapshotId;
499
488
 
@@ -0,0 +1,21 @@
1
+
2
+ export interface UndoEntry {
3
+ content: string;
4
+ bom: string;
5
+ originalEnding: "\r\n" | "\n";
6
+ hashes: string[];
7
+ }
8
+
9
+ const undoMap = new Map<string, UndoEntry>();
10
+
11
+ export function saveUndo(path: string, entry: UndoEntry): void {
12
+ undoMap.set(path, entry);
13
+ }
14
+
15
+ export function getUndo(path: string): UndoEntry | undefined {
16
+ return undoMap.get(path);
17
+ }
18
+
19
+ export function clearUndo(path: string): void {
20
+ undoMap.delete(path);
21
+ }
package/src/utils.ts CHANGED
@@ -17,16 +17,30 @@ export function cntLines(text: string): number {
17
17
  }
18
18
 
19
19
  export function rejectUnknownFields(
20
- obj: Record<string, unknown>,
21
- allowed: Set<string>,
22
- label: string,
23
- hint?: string,
20
+ obj: Record<string, unknown>,
21
+ allowed: Set<string>,
22
+ label: string,
23
+ hint?: string,
24
24
  ): void {
25
- const unknown = Object.keys(obj).filter((key) => !allowed.has(key));
26
- if (unknown.length > 0) {
27
- const suffix = hint ? ` ${hint}` : "";
28
- throw new Error(
29
- `[E_BAD_SHAPE] ${label} contains unknown or unsupported fields: ${unknown.join(", ")}.${suffix}`,
30
- );
31
- }
25
+ const unknown = Object.keys(obj).filter((key) => !allowed.has(key));
26
+ if (unknown.length > 0) {
27
+ const suffix = hint ? ` ${hint}` : "";
28
+ throw new Error(
29
+ `[E_BAD_SHAPE] ${label} contains unknown or unsupported fields: ${unknown.join(", ")}.${suffix}`,
30
+ );
31
+ }
32
+ }
33
+
34
+ export function cntDiff(diff: string, marker: "+" | "-"): number {
35
+ if (!diff) return 0;
36
+ let count = 0;
37
+ for (const line of diff.split("\n")) {
38
+ if (
39
+ line.startsWith(marker) &&
40
+ !line.startsWith(`${marker}${marker}${marker}`)
41
+ ) {
42
+ count += 1;
43
+ }
44
+ }
45
+ return count;
32
46
  }