pi-hashline-edit-pro 0.16.0 → 0.16.2

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.
103
102
 
104
- ### Auto-read after write
103
+ After a successful replace, the response confirms with `Successfully replaced in {path}. Added X line(s), removed Y line(s).` (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.
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
@@ -20,12 +20,9 @@ import { loadHashStore, pruneHashStore } from "./src/hash-store";
20
20
  export default function (pi: ExtensionAPI): void {
21
21
  regRead(pi);
22
22
 
23
- // Register the bulk-mode replace tool by default. The session_start handler
24
- // will re-register with the correct mode from the persisted config.
25
23
  regReplace(pi);
26
24
  regReplaceUndo(pi);
27
25
  const debugValue = process.env.PI_HASHLINE_DEBUG;
28
- // Initial auto-read from env var; session_start overrides with persisted value
29
26
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
30
27
  let autoRead = autoReadValue === "1" || autoReadValue === "true";
31
28
 
@@ -33,12 +30,10 @@ export default function (pi: ExtensionAPI): void {
33
30
  const active = pi.getActiveTools();
34
31
  pi.setActiveTools(active.filter((t) => t !== "edit"));
35
32
  await initHasher();
36
- // Prune stale snapshots (files that no longer exist) on session start
37
33
  try {
38
34
  const store = await loadHashStore();
39
35
  await pruneHashStore(store);
40
- } catch { /* best-effort */ }
41
- // Re-register the replace tool according to the persisted mode
36
+ } catch {}
42
37
  const mode = await readReplaceMode();
43
38
  if (mode === "flat") {
44
39
  regReplaceFlat(pi);
@@ -46,7 +41,6 @@ export default function (pi: ExtensionAPI): void {
46
41
  regReplace(pi);
47
42
  }
48
43
 
49
- // Read the persisted auto-read setting (overrides env var default)
50
44
  autoRead = await readAutoRead();
51
45
 
52
46
  if (debugValue === "1" || debugValue === "true") {
@@ -58,7 +52,6 @@ export default function (pi: ExtensionAPI): void {
58
52
  description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
59
53
  handler: async (_args, ctx) => {
60
54
  const mode = await toggleReplaceMode();
61
- // Re-register the tool with the new mode
62
55
  if (mode === "flat") {
63
56
  regReplaceFlat(pi);
64
57
  } else {
@@ -69,10 +62,9 @@ export default function (pi: ExtensionAPI): void {
69
62
  });
70
63
 
71
64
  pi.registerCommand("toggle-auto-read", {
72
- description: "Toggle automatic hashline anchors after write operations",
65
+ description: "Toggle automatic hashline anchors after write and replace operations",
73
66
  handler: async (_args, ctx) => {
74
67
  autoRead = await toggleAutoRead();
75
- // Re-register the tool so prompts reflect the new auto-read setting
76
68
  const mode = await readReplaceMode();
77
69
  if (mode === "flat") {
78
70
  regReplaceFlat(pi);
@@ -80,13 +72,14 @@ export default function (pi: ExtensionAPI): void {
80
72
  regReplace(pi);
81
73
  }
82
74
  const state = autoRead ? "enabled" : "disabled";
83
- ctx.ui.notify(`Auto-read after write: ${state}`, "info");
75
+ ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
84
76
  },
85
77
  });
86
78
 
87
79
  pi.on("tool_result", async (event, ctx) => {
88
80
  if (!autoRead) return;
89
- if (event.toolName !== "write" || event.isError) return;
81
+ if (event.isError) return;
82
+ if (event.toolName !== "write" && event.toolName !== "replace") return;
90
83
 
91
84
  const filePath = (event.input as Record<string, unknown>)?.path;
92
85
  if (typeof filePath !== "string") return;
@@ -108,7 +101,7 @@ export default function (pi: ExtensionAPI): void {
108
101
  ],
109
102
  };
110
103
  } catch (error) {
111
- console.error("Auto-read after write failed:", error);
104
+ console.error("Auto-read after write/replace failed:", error);
112
105
  }
113
106
  });
114
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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.
@@ -91,6 +91,7 @@ Rules:
91
91
  - `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix. A line that happens to start with `+` or `-` is written as-is; the only rejected form is the diff preview's `+HASH│…` row (see `[E_INVALID_PATCH]`).
92
92
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged. Dropping indentation will produce broken code.
93
93
  - Don't add `""` for spacing unless you actually want a new blank line.
94
+ - `changes`, `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.
94
95
  - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
95
96
  - All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
96
97
  - If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
@@ -127,5 +128,6 @@ Error recovery:
127
128
  - `[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.
128
129
  - `[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.)
129
130
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
131
+ - `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
130
132
 
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.
133
+ **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.
@@ -113,5 +113,6 @@ Error recovery:
113
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.
114
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.)
115
115
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
116
+ - `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
116
117
 
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.
118
+ **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.
@@ -177,5 +177,6 @@ Error recovery:
177
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.
178
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.)
179
179
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
180
+ - `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
180
181
 
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.
182
+ **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;
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,12 +9,6 @@ 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
- * Returns a warning if any item was a JSON string.
17
- */
18
12
  function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
19
13
  const warnings: string[] = [];
20
14
  const result = items
@@ -43,12 +37,6 @@ function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: strin
43
37
  return { result, warnings };
44
38
  }
45
39
 
46
- /**
47
- * Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
48
- * single objects → wrapped in array. Shared by the `changes` and `edits`
49
- * normalization branches.
50
- * Returns a warning if the field was a JSON string.
51
- */
52
40
  function normalizeField(
53
41
  record: Record<string, unknown>,
54
42
  from: string,
@@ -85,6 +73,19 @@ function normalizeField(
85
73
  return undefined;
86
74
  }
87
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
+ }
87
+ }
88
+
88
89
  export function normReq(input: unknown): unknown {
89
90
  if (!isRec(input)) {
90
91
  return input;
@@ -93,18 +94,13 @@ export function normReq(input: unknown): unknown {
93
94
  const record: Record<string, unknown> = { ...input };
94
95
  const warnings: string[] = [];
95
96
 
96
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
97
- record.path = record.file_path;
98
- delete record.file_path;
99
- }
97
+ normalizeFilePath(record);
100
98
 
101
99
  const w1 = normalizeField(record, "changes", "changes");
102
100
  if (w1) warnings.push(w1);
103
101
  const w2 = normalizeField(record, "edits", "changes");
104
102
  if (w2) warnings.push(w2);
105
103
 
106
- // Handle flat format: hash_range_inclusive and content_lines at top level
107
- // (no changes array). Wrap them into a single-element changes array.
108
104
  if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
109
105
  const hriRaw = record.hash_range_inclusive;
110
106
  const hri = tryParseJSON(hriRaw, (v): v is string[] =>
@@ -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";
@@ -7,29 +7,17 @@ import { loadHashStore, saveHashStore } from "./hash-store";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./path-utils";
9
9
  import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
10
+ import { cntDiff } from "./utils";
11
+ import { loadP, loadGuide } from "./prompts";
10
12
 
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
13
 
25
14
  export function regReplaceUndo(pi: ExtensionAPI): void {
26
15
  pi.registerTool({
27
- name: "last_replace_undo",
16
+ name: "undo_last_replace",
28
17
  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.",
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"),
33
21
  parameters: Type.Object({
34
22
  path: Type.String({
35
23
  description: "Path to the file to undo the last replace on",
@@ -56,29 +44,24 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
56
44
  }
57
45
 
58
46
  return withFileMutationQueue(mutationTargetPath, async () => {
59
- // Read current file content to compute diff against the pre-replace state
60
47
  let currentNormalized = "";
61
48
  try {
62
49
  const currentRaw = await readFile(mutationTargetPath, "utf-8");
63
50
  const { text: currentStripped } = stripBOM(currentRaw);
64
51
  currentNormalized = toLF(currentStripped);
65
52
  } catch {
66
- // File may have been deleted; treat as empty
67
53
  currentNormalized = "";
68
54
  }
69
55
 
70
- // Compute diff: old = pre-replace (undo.content), new = current
71
56
  const diffResult = genDiff(undo.content, currentNormalized, 0);
72
57
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
73
58
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
74
59
 
75
- // Write the pre-edit content back to the file
76
60
  await writeAtomic(
77
61
  mutationTargetPath,
78
62
  undo.bom + restoreEndings(undo.content, undo.originalEnding),
79
63
  );
80
64
 
81
- // Restore the hash store snapshot so the next read returns the same hashes
82
65
  const store = await loadHashStore();
83
66
  store.snapshots[mutationTargetPath] = {
84
67
  content: undo.content,
@@ -86,7 +69,6 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
86
69
  };
87
70
  await saveHashStore(store);
88
71
 
89
- // Clear undo so a second call without an intervening replace is a no-op
90
72
  clearUndo(mutationTargetPath);
91
73
 
92
74
  const parts: string[] = [
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";
@@ -76,11 +76,6 @@ export const editToolSchema = Type.Object(
76
76
  { additionalProperties: false },
77
77
  );
78
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
79
  export const flatEditToolSchema = Type.Object(
85
80
  {
86
81
  path: Type.String({ description: "path" }),
@@ -181,12 +176,6 @@ export async function execPipeline(
181
176
 
182
177
  const result = anchorResult.content;
183
178
 
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 }).
190
179
  const removedHashes = new Set<string>();
191
180
  for (const edit of resolved) {
192
181
  const startHash = edit.hash_range_inclusive[0].hash;
@@ -200,14 +189,12 @@ export async function execPipeline(
200
189
  }
201
190
  }
202
191
 
203
- // Compute stable result hashes using hash-aware preservation
204
192
  const resultHashes = await lineHashes(result, resolvedPath, {
205
193
  content: originalNormalized,
206
194
  hashes: originalHashes,
207
195
  removedHashes,
208
196
  });
209
197
 
210
- // Format boundary warnings using stable result hashes
211
198
  const resultLines = result.split("\n");
212
199
  const warnings = [...(anchorResult.warnings ?? [])];
213
200
  for (const bw of anchorResult.boundaryWarnings ?? []) {
@@ -299,7 +286,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
299
286
  export function buildToolDef(opts: { flat: boolean }): ToolDef {
300
287
  const autoRead = readAutoReadSync();
301
288
  const readGuidance = autoRead
302
- ? "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."
303
290
  : "Call `read` to get fresh anchors for follow-up edits.";
304
291
 
305
292
  const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
@@ -321,20 +308,11 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
321
308
  promptGuidelines: E_GUIDE,
322
309
  prepareArguments: opts.flat
323
310
  ? (args: unknown) => {
324
- // Minimal normalization: file_path → path, JSON string parsing.
325
- // The flat-to-canonical conversion happens in execute().
326
311
  if (!isRec(args)) return args as any;
327
312
  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
- }
313
+ normalizeFilePath(record);
314
+ tryParseField(record, "hash_range_inclusive");
315
+ tryParseField(record, "content_lines");
338
316
  return record as any;
339
317
  }
340
318
  : (args: unknown) =>
@@ -428,8 +406,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
428
406
  },
429
407
 
430
408
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
431
- // Flat mode: wrap top-level fields into a single-element changes array.
432
- // Bulk mode: use params as-is after normReq normalization.
433
409
  const canonical = opts.flat
434
410
  ? normReq({
435
411
  path: (params as any).path,
package/src/undo-store.ts CHANGED
@@ -1,22 +1,8 @@
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
1
 
12
2
  export interface UndoEntry {
13
- /** Normalized (LF) content before the edit */
14
3
  content: string;
15
- /** BOM prefix that was stripped from the original file */
16
4
  bom: string;
17
- /** Original line ending detected before the edit */
18
5
  originalEnding: "\r\n" | "\n";
19
- /** Hash array for the pre-edit content */
20
6
  hashes: string[];
21
7
  }
22
8
 
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
  }