pi-hashline-edit-pro 0.18.1 → 0.18.3

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
@@ -94,7 +94,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
94
94
 
95
95
  ### Stable hashing across edits
96
96
 
97
- Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
97
+ Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions. A replace that produces identical content (a no-op, reported as "No changes made") never rotates hashes: no file change means no anchor change, so previously read anchors remain valid after a no-op.
98
98
 
99
99
  The store is a SQLite database (WAL journal mode) keyed by canonical file path. Each snapshot stores a 64-bit content checksum (`xxhash64`) plus the per-line hashes, not the full text, so a cache hit is a single keyed lookup and a one-row write. Reads, replaces, undo, and pruning all share one transactional store, so concurrent Pi sessions editing different files never silently clobber each other's snapshots (per-path writers serialize via `BEGIN IMMEDIATE`; same-path concurrent edits still fail safe — stale anchors are rejected by content matching). Stale snapshots (for files that no longer exist) are pruned on session start.
100
100
 
@@ -138,16 +138,34 @@ Settings are stored in `~/.config/pi-hashline-edit-pro/config.json`:
138
138
 
139
139
  The file is created automatically when any setting is toggled. Both fields are independent — toggling one never clobbers the other.
140
140
 
141
+ ### Error codes
142
+
143
+ | Code | Meaning |
144
+ | --- | --- |
145
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields. |
146
+ | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
147
+ | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
148
+ | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
149
+ | `[E_INVALID_PATCH]` | `content_lines` contains diff-preview rows (`+HASH│`, `-HASH│`, `- │`, `-N `). |
150
+ | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
151
+ | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
152
+ | `[E_BAD_OP]` | Range start line is after range end line. |
153
+ | `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
154
+ | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
155
+ | `[E_FILE_TOO_LARGE]` | The file exceeds the 262,144-line hashline limit. |
156
+
141
157
  ## Design Decisions
142
158
 
143
159
  - **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
144
160
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
145
- - **Strict patch content.** If `content_lines` contains `+HASH│` display prefixes (or `-N ` numbered deletion rows), the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
161
+ - **Strict patch content.** If `content_lines` contains diff-preview rows — `+HASH│` addition prefixes, `-HASH│` or `- │` deletion rows (the padded format the diff preview emits), or `-N ` numbered deletion rows the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
162
+
163
+ - **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
146
164
  - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
147
165
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
148
166
  - **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
149
167
  - **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
150
- - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history.
168
+ - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history. If the database is corrupt or unreadable it is quarantined (renamed to `hash-store.sqlite.corrupt-<timestamp>`) and rebuilt from content on the next session start — the store is a cache, never a source of truth.
151
169
  ## Hashing
152
170
 
153
171
  Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
@@ -160,6 +178,8 @@ Before hashing, each line is normalized: carriage returns are stripped and trail
160
178
  The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
161
179
  `HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
162
180
 
181
+ The 3-character space holds 262,144 unique anchors, so files are capped at 262,144 lines: `read` and `replace` reject larger files with `[E_FILE_TOO_LARGE]` (use `write` or a non-line-based approach for very large files).
182
+
163
183
  ### Bare-prefix detector
164
184
 
165
185
  With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
package/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { initHasher } from "./src/hashline";
3
3
  import { regReplace, regReplaceFlat } from "./src/replace";
4
- import { regReplaceUndo } from "./src/replace-undo";
4
+ import { regReplaceUndo, clearUndo } from "./src/replace-undo";
5
5
  import { regRead, fmtReadPreview } from "./src/read";
6
6
  import { visLines } from "./src/utils";
7
7
  import { AUTO_READ_MAX } from "./src/constants";
8
+ import { MAX_HASH_LINES } from "./src/hashline";
8
9
  import {
9
10
  readConfig,
10
11
  toggleReplaceMode,
@@ -12,7 +13,8 @@ import {
12
13
  } from "./src/config";
13
14
  import { loadHashStore, pruneMissing } from "./src/hash-store";
14
15
  import { readNormFile } from "./src/file-reader";
15
-
16
+ import { toCwd } from "./src/paths";
17
+ import { resolveTarget } from "./src/fs-write";
16
18
  function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
17
19
  if (mode === "flat") {
18
20
  regReplaceFlat(pi, autoRead);
@@ -73,16 +75,27 @@ export default function (pi: ExtensionAPI): void {
73
75
  });
74
76
 
75
77
  pi.on("tool_result", async (event, ctx) => {
76
- if (!autoRead) return;
77
78
  if (event.isError) return;
79
+ if (event.toolName === "write") {
80
+ const writtenPath = (event.input as Record<string, unknown>)?.path;
81
+ if (typeof writtenPath === "string") {
82
+ try {
83
+ clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
84
+ } catch (error) {
85
+ console.error("Failed to clear undo after write:", error);
86
+ }
87
+ }
88
+ }
89
+ if (!autoRead) return;
78
90
  if (event.toolName !== "write" && event.toolName !== "replace") return;
79
91
 
80
92
  const filePath = (event.input as Record<string, unknown>)?.path;
81
93
  if (typeof filePath !== "string") return;
82
94
 
83
95
  try {
84
- const { normalized, fileHashes, absolutePath } = await readNormFile(filePath, ctx.cwd);
85
-
96
+ const { normalized, fileHashes, absolutePath } = await readNormFile(
97
+ filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
98
+ );
86
99
  if (visLines(normalized).length === 0) return;
87
100
 
88
101
  const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
6
6
  "main": "index.ts",
@@ -40,6 +40,9 @@
40
40
  "@earendil-works/pi-coding-agent": ">=0.74.0",
41
41
  "@earendil-works/pi-tui": "*"
42
42
  },
43
+ "engines": {
44
+ "node": ">=22.13.0"
45
+ },
43
46
  "scripts": {
44
47
  "test": "vitest run",
45
48
  "test:watch": "vitest",
@@ -1 +1,2 @@
1
- - `read`: call before `replace` when you need fresh HASH anchors for a file.
1
+ - `read`: call before `replace` when you need fresh HASH anchors for a file.
2
+ - `read`: call again after any edit to that file — changed lines get new anchors.
package/prompts/read.md CHANGED
@@ -1,3 +1 @@
1
- Read a text file. Each line returned as HASH│content (3-char URL-safe base64 hash). No line numbers — use the 3-char HASH to reference lines in replace calls.
2
-
3
- Text → HASH│content lines. Images → visual attachments. Binary/directory → rejected. Empty → HASH│ (replace to insert). Pageable with offset/limit. BOM stripped; non-UTF-8 shown as U+FFFD.
1
+ Read a text file; each line returned as HASH│content with a 3-char URL-safe base64 hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
@@ -1,2 +1,2 @@
1
- - `replace`: Provide hash_range_inclusive [start_hash, end_hash] targeting lines by their 3-char hashes (replaces every line from first anchor through last anchor inclusively), and content_lines as a native JSON array of strings for the replacement text.
2
- - `replace`: preserve leading whitespace exactly as it appears after in read output.
1
+ - `replace`: content_lines is a native JSON array of strings never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
2
+ - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
@@ -0,0 +1 @@
1
+ - `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { readFile, writeFile, mkdir } from "fs/promises";
2
- import { configDir, configPath } from "./paths";
1
+ import { readFile } from "fs/promises";
2
+ import { configPath } from "./paths";
3
3
  import { errCode } from "./utils";
4
+ import { writeAtomic } from "./fs-write";
4
5
 
5
6
  export type ReplaceMode = "bulk" | "flat";
6
7
  export interface Config {
@@ -32,8 +33,7 @@ export async function readConfig(): Promise<Config> {
32
33
  }
33
34
  }
34
35
  export async function writeConfig(config: Config): Promise<void> {
35
- await mkdir(configDir(), { recursive: true });
36
- await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
36
+ await writeAtomic(configPath(), JSON.stringify(config, null, 2));
37
37
  }
38
38
 
39
39
 
package/src/constants.ts CHANGED
@@ -2,9 +2,6 @@ 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
- export const MAX_HASH_LINES = 1_000_000;
6
- export const MAX_HASH_RETRIES = 262_144;
7
-
8
5
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
9
6
  export const HASH_STORE_VERSION = 3;
10
7
  export const CONTENT_LINES_NOT_STRING_MSG =
package/src/file-kind.ts CHANGED
@@ -81,7 +81,7 @@ export async function loadFileKindAndText(
81
81
  }
82
82
 
83
83
 
84
- const decoder = new TextDecoder("utf-8", { fatal: false });
84
+ const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
85
85
  let hadUtf8DecodeErrors = false;
86
86
  const parts: string[] = [];
87
87
 
package/src/fs-write.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  lstat,
4
4
  mkdir,
5
5
  open,
6
+ readdir,
6
7
  readlink,
7
8
  rename,
8
9
  rm,
@@ -71,6 +72,44 @@ export async function resolveTarget(path: string): Promise<string> {
71
72
  return resParts(root, parts);
72
73
  }
73
74
 
75
+ const TEMP_PREFIX = ".tmp-";
76
+ const STALE_TEMP_MS = 60 * 60 * 1000;
77
+ const sweptDirs = new Set<string>();
78
+
79
+ async function sweepStaleTemps(dir: string): Promise<void> {
80
+ if (sweptDirs.has(dir)) return;
81
+ sweptDirs.add(dir);
82
+ try {
83
+ const entries = await readdir(dir, { withFileTypes: true });
84
+ const now = Date.now();
85
+ for (const entry of entries) {
86
+ if (!entry.isFile() || !entry.name.startsWith(TEMP_PREFIX)) continue;
87
+ const tempPath = join(dir, entry.name);
88
+ try {
89
+ const stats = await stat(tempPath);
90
+ if (now - stats.mtimeMs > STALE_TEMP_MS) {
91
+ await rm(tempPath, { force: true });
92
+ }
93
+ } catch {
94
+ }
95
+ }
96
+ } catch {
97
+ }
98
+ }
99
+
100
+ async function syncDir(dir: string): Promise<void> {
101
+ if (process.platform === "win32") return;
102
+ try {
103
+ const handle = await open(dir, "r");
104
+ try {
105
+ await handle.sync();
106
+ } finally {
107
+ await handle.close();
108
+ }
109
+ } catch {
110
+ }
111
+ }
112
+
74
113
  export async function writeAtomic(
75
114
  path: string,
76
115
  content: string,
@@ -92,7 +131,8 @@ export async function writeAtomic(
92
131
  }
93
132
 
94
133
  const dir = dirname(targetPath);
95
- const tempPath = join(dir, `.tmp-${randomUUID()}`);
134
+ await sweepStaleTemps(dir);
135
+ const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`);
96
136
  await mkdir(dir, { recursive: true });
97
137
  const tempHandle = await open(tempPath, "wx", 0o600);
98
138
  try {
@@ -100,6 +140,7 @@ export async function writeAtomic(
100
140
  if (existingStats) {
101
141
  await tempHandle.chmod(existingStats.mode & 0o7777);
102
142
  }
143
+ await tempHandle.sync();
103
144
  } catch (error: unknown) {
104
145
  await tempHandle.close();
105
146
  try { await rm(tempPath, { force: true }); } catch {}
@@ -108,12 +149,13 @@ export async function writeAtomic(
108
149
  try {
109
150
  await tempHandle.close();
110
151
  await rename(tempPath, targetPath);
152
+ await syncDir(dir);
111
153
  } catch (error: unknown) {
112
154
  if (errCode(error) === "EXDEV") {
113
155
  try {
114
- await tempHandle.close();
115
156
  await copyFile(tempPath, targetPath);
116
157
  await rm(tempPath, { force: true });
158
+ await syncDir(dir);
117
159
  return;
118
160
  } catch {
119
161
  try { await rm(tempPath, { force: true }); } catch {}
package/src/hash-store.ts CHANGED
@@ -40,8 +40,7 @@ let exitHandlerRegistered = false;
40
40
  function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
41
41
  const db = new DatabaseSync(storePath, {
42
42
  timeout: HASH_STORE_BUSY_TIMEOUT,
43
- defensive: false,
44
- } as any);
43
+ });
45
44
  db.exec("PRAGMA journal_mode = WAL");
46
45
  db.exec("PRAGMA synchronous = NORMAL");
47
46
  db.exec(
@@ -72,6 +71,36 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
72
71
  return { db, stmts };
73
72
  }
74
73
 
74
+ function isHealthy(db: DatabaseSync): boolean {
75
+ try {
76
+ const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
77
+ return row?.quick_check === "ok";
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ async function quarantineStore(storePath: string): Promise<void> {
84
+ const suffix = `.corrupt-${Date.now()}`;
85
+ for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
86
+ try {
87
+ await rename(candidate, `${candidate}${suffix}`);
88
+ } catch (error) {
89
+ if (errCode(error) !== "ENOENT") {
90
+ console.error("Failed to quarantine corrupt hash store file:", error);
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ function shutdownDb(db: DatabaseSync): void {
97
+ try {
98
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
99
+ } catch {
100
+ }
101
+ db.close();
102
+ }
103
+
75
104
  export async function loadHashStore(): Promise<HashStore> {
76
105
  const storePath = hashStorePath();
77
106
  if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
@@ -83,13 +112,27 @@ export async function loadHashStore(): Promise<HashStore> {
83
112
  await initHasher();
84
113
  await mkdir(hashStoreDir(), { recursive: true });
85
114
 
86
- const existed = existsSync(storePath);
87
- const { db, stmts } = openDb(storePath);
115
+ let existed = existsSync(storePath);
116
+ let opened: { db: DatabaseSync; stmts: Prepared };
117
+ try {
118
+ opened = openDb(storePath);
119
+ } catch (error) {
120
+ console.error("Hash store failed to open, rebuilding:", error);
121
+ await quarantineStore(storePath);
122
+ existed = false;
123
+ opened = openDb(storePath);
124
+ }
125
+ if (!isHealthy(opened.db)) {
126
+ shutdownDb(opened.db);
127
+ await quarantineStore(storePath);
128
+ existed = false;
129
+ opened = openDb(storePath);
130
+ }
131
+ const { db, stmts } = opened;
88
132
 
89
133
  if (!existed) {
90
134
  await migrateLegacy(db);
91
135
  }
92
-
93
136
  cachedDb = { path: storePath, db, stmts };
94
137
 
95
138
  if (!exitHandlerRegistered) {
@@ -108,11 +151,7 @@ export async function loadHashStore(): Promise<HashStore> {
108
151
 
109
152
  export function shutdownHashStore(): void {
110
153
  if (cachedDb) {
111
- try {
112
- cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
113
- } catch {
114
- }
115
- cachedDb.db.close();
154
+ shutdownDb(cachedDb.db);
116
155
  cachedDb = null;
117
156
  }
118
157
  }
@@ -21,6 +21,9 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
21
21
  const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
22
22
  export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
23
23
 
24
+ export const HASH_SPACE = ALPH.length ** HASH_LEN;
25
+ export const MAX_HASH_LINES = HASH_SPACE;
26
+
24
27
  function idxToHash(idx: number): string {
25
28
  let out = "";
26
29
  for (let j = 0; j < HASH_LEN; j++) {
@@ -30,7 +33,7 @@ function idxToHash(idx: number): string {
30
33
  }
31
34
 
32
35
  const HASH_TABLE: string[] = Array.from(
33
- { length: 262_144 },
36
+ { length: HASH_SPACE },
34
37
  (_, i) => idxToHash(i),
35
38
  );
36
39
 
@@ -40,6 +43,9 @@ export const HL_PREFIX_RE = new RegExp(
40
43
  export const HL_PREFIX_PLUS_RE = new RegExp(
41
44
  `^\\+\\s*${HASH_CLASS}│`,
42
45
  );
46
+ export const HL_PREFIX_MINUS_RE = new RegExp(
47
+ `^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
48
+ );
43
49
  export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
44
50
 
45
51
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
@@ -48,7 +54,7 @@ function canon(line: string): string {
48
54
  return line.replace(/\r/g, "").trimEnd();
49
55
  }
50
56
 
51
- const BITSET_WORDS = 8192;
57
+ const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
52
58
 
53
59
  function getBit(bits: Uint32Array, idx: number): boolean {
54
60
  return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
@@ -95,7 +101,9 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
95
101
  if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
96
102
  }
97
103
 
98
- throw new Error("Hash space exhausted");
104
+ throw new Error(
105
+ `[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
106
+ );
99
107
  }
100
108
 
101
109
  function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
@@ -172,6 +180,47 @@ function hashToIndex(hash: string): number {
172
180
  return idx;
173
181
  }
174
182
 
183
+ function findNearestCandidate(
184
+ candidates: { index: number; hash: string }[],
185
+ target: number,
186
+ removedHashes?: Set<string>,
187
+ ): number {
188
+ let lo = 0;
189
+ let hi = candidates.length;
190
+ while (lo < hi) {
191
+ const mid = (lo + hi) >>> 1;
192
+ if (candidates[mid]!.index < target) lo = mid + 1;
193
+ else hi = mid;
194
+ }
195
+ let left = lo - 1;
196
+ let right = lo;
197
+ while (left >= 0 || right < candidates.length) {
198
+ let bestPos = -1;
199
+ let bestDist = Infinity;
200
+ if (left >= 0) {
201
+ const candidate = candidates[left]!;
202
+ if (!removedHashes?.has(candidate.hash)) {
203
+ bestPos = left;
204
+ bestDist = target - candidate.index;
205
+ }
206
+ }
207
+ if (right < candidates.length) {
208
+ const candidate = candidates[right]!;
209
+ if (!removedHashes?.has(candidate.hash)) {
210
+ const dist = candidate.index - target;
211
+ if (dist < bestDist) {
212
+ bestPos = right;
213
+ bestDist = dist;
214
+ }
215
+ }
216
+ }
217
+ if (bestPos >= 0) return bestPos;
218
+ left--;
219
+ right++;
220
+ }
221
+ return -1;
222
+ }
223
+
175
224
  function mapStableHashes(
176
225
  oldContent: string,
177
226
  oldHashes: string[],
@@ -208,18 +257,8 @@ function mapStableHashes(
208
257
  const candidates = contentMap.get(line);
209
258
  if (!candidates || candidates.length === 0) continue;
210
259
 
211
- let bestIdx = 0;
212
- let bestDist = Infinity;
213
- for (let j = 0; j < candidates.length; j++) {
214
- if (removedHashes?.has(candidates[j]!.hash)) continue;
215
- const dist = Math.abs(candidates[j]!.index - i);
216
- if (dist < bestDist) {
217
- bestDist = dist;
218
- bestIdx = j;
219
- }
220
- }
221
-
222
- if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
260
+ const bestIdx = findNearestCandidate(candidates, i, removedHashes);
261
+ if (bestIdx < 0) continue;
223
262
  const match = candidates.splice(bestIdx, 1)[0]!;
224
263
  newHashes[i] = match.hash;
225
264
  const matchIdx = hashToIndex(match.hash);
@@ -3,8 +3,11 @@ export {
3
3
  ANCHOR_LEN,
4
4
  HASH_SEP,
5
5
  HASH_CLASS,
6
+ HASH_SPACE,
7
+ MAX_HASH_LINES,
6
8
  HL_PREFIX_RE,
7
9
  HL_PREFIX_PLUS_RE,
10
+ HL_PREFIX_MINUS_RE,
8
11
  DIFF_MINUS_RE,
9
12
  HL_BARE_PREFIX_RE,
10
13
  lineHashes,
@@ -2,6 +2,7 @@ import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
4
  HL_PREFIX_PLUS_RE,
5
+ HL_PREFIX_MINUS_RE,
5
6
  DIFF_MINUS_RE,
6
7
  } from "./hash";
7
8
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
@@ -46,10 +47,11 @@ function assertNoPrefixes(lines: string[]): void {
46
47
  if (!line.length) continue;
47
48
  if (
48
49
  HL_PREFIX_PLUS_RE.test(line) ||
50
+ HL_PREFIX_MINUS_RE.test(line) ||
49
51
  DIFF_MINUS_RE.test(line)
50
52
  ) {
51
53
  throw new Error(
52
- `[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like the diff preview's +HASH│ row: ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
54
+ `[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
53
55
  );
54
56
  }
55
57
  }
package/src/read.ts CHANGED
@@ -2,15 +2,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  createReadTool,
4
4
  formatSize,
5
- DEFAULT_MAX_BYTES,
6
- DEFAULT_MAX_LINES,
7
5
  truncateHead,
8
6
  type TruncationResult,
9
7
  } from "@earendil-works/pi-coding-agent";
10
8
  import { Type } from "typebox";
11
9
  import { loadFileKindAndText } from "./file-kind";
12
10
  import { readNormFile } from "./file-reader";
13
- import { lineHashes, fmtRegion, HASH_SEP } from "./hashline";
11
+ import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
14
12
  import { toCwd } from "./paths";
15
13
  import { abortIf } from "./utils";
16
14
  import { fileSnap } from "./file-reader";
@@ -18,10 +16,7 @@ import { visLines } from "./utils";
18
16
  import { loadP, loadGuide } from "./prompts";
19
17
  import { valAccess } from "./validation";
20
18
 
21
- const R_DESC = loadP("../prompts/read.md", {
22
- DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
23
- DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
24
- });
19
+ const R_DESC = loadP("../prompts/read.md");
25
20
 
26
21
  const R_SNIPPET = loadP("../prompts/read-snippet.md");
27
22
  const R_GUIDE = loadGuide("../prompts/read-guidelines.md");
@@ -164,7 +159,7 @@ export function regRead(pi: ExtensionAPI): void {
164
159
  return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
165
160
  }
166
161
  const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
167
- rawPath, ctx.cwd, { signal, preloadedFile: file },
162
+ rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
168
163
  );
169
164
  const preview = await fmtReadPreview(
170
165
  normalized,
package/src/replace.ts CHANGED
@@ -13,12 +13,11 @@ import {
13
13
  import { readNormFile } from "./file-reader";
14
14
  import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
15
15
  import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
16
- import { MAX_HASH_LINES } from "./constants";
17
16
  import { resolveTarget, writeAtomic } from "./fs-write";
18
- import {
19
- applyEdits,
17
+ import { applyEdits,
20
18
  lineHashes,
21
19
  resEdits,
20
+ MAX_HASH_LINES,
22
21
  type HTEdit,
23
22
  } from "./hashline";
24
23
  import { toCwd } from "./paths";
@@ -68,7 +67,7 @@ const changeItemSchema = Type.Object(
68
67
 
69
68
  export const editToolSchema = Type.Object(
70
69
  {
71
- changes: Type.Array(changeItemSchema, { description: "Array of edits. Each edit pairs content_lines (literal file content, one string per line) with hash_range_inclusive (inclusive [start_hash, end_hash] — pair of 3-char hashes from read output)." }),
70
+ changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
72
71
  path: Type.String({ description: "Path to edit" }),
73
72
  },
74
73
  { additionalProperties: false },
@@ -117,6 +116,19 @@ interface PipelineResult {
117
116
 
118
117
  const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
119
118
 
119
+ const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
120
+
121
+ export function assertNoLegacyKeys(request: unknown): void {
122
+ if (!isRec(request)) return;
123
+ for (const legacyKey of LEGACY_KS) {
124
+ if (has(request, legacyKey)) {
125
+ throw new Error(
126
+ `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
127
+ );
128
+ }
129
+ }
130
+ }
131
+
120
132
  export function assertReq(
121
133
  request: unknown,
122
134
  flat?: boolean
@@ -125,13 +137,7 @@ export function assertReq(
125
137
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
126
138
  }
127
139
 
128
- for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
129
- if (has(request, legacyKey)) {
130
- throw new Error(
131
- `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
132
- );
133
- }
134
- }
140
+ assertNoLegacyKeys(request);
135
141
 
136
142
  rejectUnknownFields(request, ROOT_KS, "Edit request");
137
143
 
@@ -159,9 +165,11 @@ export interface ExecPipelineOptions {
159
165
  function collectRemovedHashes(
160
166
  resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
161
167
  originalHashes: string[],
168
+ skipIndices?: Set<number>,
162
169
  ): Set<string> {
163
170
  const removedHashes = new Set<string>();
164
- for (const edit of resolved) {
171
+ for (const [index, edit] of resolved.entries()) {
172
+ if (skipIndices?.has(index)) continue;
165
173
  const startHash = edit.hash_range_inclusive[0].hash;
166
174
  const endHash = edit.hash_range_inclusive[1].hash;
167
175
  const startLine = originalHashes.indexOf(startHash);
@@ -227,16 +235,20 @@ export async function execPipeline(
227
235
  );
228
236
 
229
237
  const result = anchorResult.content;
230
-
231
- const removedHashes = collectRemovedHashes(resolved, originalHashes);
238
+ const isNoop = result === originalNormalized;
232
239
 
233
240
  const noPersist = options?.noPersist;
234
- const resultHashes = await lineHashes(result, absolutePath, {
235
- content: originalNormalized,
236
- hashes: originalHashes,
237
- removedHashes,
238
- }, hashStore, noPersist !== true);
239
-
241
+ const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
242
+ const removedHashes = isNoop
243
+ ? undefined
244
+ : collectRemovedHashes(resolved, originalHashes, noopIndices);
245
+ const resultHashes = isNoop
246
+ ? originalHashes
247
+ : await lineHashes(result, absolutePath, {
248
+ content: originalNormalized,
249
+ hashes: originalHashes,
250
+ removedHashes,
251
+ }, hashStore, noPersist !== true);
240
252
  const warnings = [...(anchorResult.warnings ?? [])];
241
253
 
242
254
  const { totalAddedLines, totalRemovedLines } = countLineChanges(
@@ -269,6 +281,11 @@ export async function compPreview(
269
281
  ): Promise<RPreview> {
270
282
  try {
271
283
  const normalized = normReq(request);
284
+ if (flat && isRec(request) && Array.isArray(request.changes)) {
285
+ return {
286
+ error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send content_lines and hash_range_inclusive at the top level (one edit per call), or use bulk mode for multiple edits per call.`
287
+ };
288
+ }
272
289
  assertReq(normalized, flat);
273
290
  const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
274
291
  normalized,
@@ -312,25 +329,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
312
329
 
313
330
  const MODE_CFG = {
314
331
  flat: {
315
- desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
316
- examples: [
317
- "", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
318
- ].join("\n"),
319
- rules: "",
320
- requestStructure: [
321
- "Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
322
- ].join("\n"),
323
332
  prefix: "performing one edit per call",
324
333
  },
325
334
  bulk: {
326
- desc: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.",
327
- examples: [
328
- "", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
329
- ].join("\n"),
330
- rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
331
- requestStructure: [
332
- "Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
333
- ].join("\n"),
334
335
  prefix: "batching all changes to a file in one call",
335
336
  },
336
337
  } as const;
@@ -355,6 +356,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
355
356
  promptGuidelines: E_GUIDE,
356
357
  prepareArguments: opts.flat
357
358
  ? (args: unknown) => {
359
+ assertNoLegacyKeys(args);
358
360
  if (!isRec(args)) return args as any;
359
361
  const record = { ...args };
360
362
  normalizeFilePath(record);
@@ -363,8 +365,10 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
363
365
  }
364
366
  return record;
365
367
  }
366
- : (args: unknown) =>
367
- normReq(args) as ReqParams,
368
+ : (args: unknown) => {
369
+ assertNoLegacyKeys(args);
370
+ return normReq(args) as ReqParams;
371
+ },
368
372
  renderShell: "default",
369
373
  renderCall(args, theme, context) {
370
374
  const previewInput = getPreviewInput(args);