pi-hashline-edit-pro 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. Every line gets a unique content hash, so edits stay precise and stale anchors are caught before they reach the file.
3
+ A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no fuzzy fallback. Unambiguous copy-paste mistakes are autocorrected with a visible warning. Every line gets a unique content hash, so edits stay precise and stale anchors are caught before they reach the file.
4
4
 
5
5
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design with 3-character hashes and collision resolution for unique per-line anchors.
6
6
 
@@ -163,10 +163,10 @@ The file is created automatically when any setting is toggled. Both fields are i
163
163
  | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
164
164
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
165
165
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
166
- | `[E_INVALID_PATCH]` | `content_lines` contains diff-preview rows (`+HASH│`, `-HASH│`, `- │`, `-N `). |
167
- | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
166
+ | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
167
+ | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
168
168
  | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
169
- | `[E_BAD_OP]` | Range start line is after range end line. |
169
+ | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
170
170
  | `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
171
171
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
172
172
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
@@ -175,19 +175,20 @@ The file is created automatically when any setting is toggled. Both fields are i
175
175
 
176
176
  - **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. When a range has one stale and one still-valid anchor, the error also shows the current lines (with fresh hashes) around the resolved anchor, so the model can re-locate the range without a full re-read. 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.
177
177
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
178
- - **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 alphanumeric 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.
178
+ - **Strict patch content.** Diff-preview rows pasted into `content_lines` are autocorrected instead of rejected: `+HASH│` addition prefixes and `-HASH│` / `- │` deletion rows (the padded format the diff preview emits) have their markers stripped, with a warning, and only the literal content is written. `-N ` numbered deletion rows and standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** altered — they are written literally, since literal content must never be silently changed when the intent is ambiguous. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 alphanumeric chars + `│`) is autocorrected: the prefix is stripped and only the literal content after `│` is written, with a warning. When the stripped hash happens to match a real file-line anchor, the warning flags that as strong evidence the model copied an anchor from the read output.
179
179
 
180
180
  - **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.
181
181
  - **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.
182
182
  - **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.
183
183
  - **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.
184
+ - **Reversed range auto-swap.** When `hash_range_inclusive` is given in reverse order (start anchor after end anchor), both anchors are still valid file lines, so the intent is unambiguous: the pair is swapped automatically and the edit applies, with a warning.
184
185
  - **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.
185
186
  - **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.
186
187
  ## Hashing
187
188
 
188
189
  Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the alphanumeric alphabet `A-Za-z0-9`. That's 62 distinct characters, 62³ = 238,328 possible anchors (≈17.9 bits of entropy per anchor).
189
190
 
190
- The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply — case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded: a hash starting with `-` is shape-identical to a diff-preview deletion row (`-HASH│`), and `-`/`_` at a line start are markdown-active (list bullet, emphasis), so they invite mis-copying and false `[E_INVALID_PATCH]`/`[E_BARE_HASH_PREFIX]` rejections. The 9% hash-space cost (238,328 vs 262,144) is irrelevant for real files.
191
+ The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply — case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded: a hash starting with `-` is shape-identical to a diff-preview deletion row (`-HASH│`), and `-`/`_` at a line start are markdown-active (list bullet, emphasis), so they invite mis-copying and false `[E_INVALID_PATCH]`/`[E_BARE_HASH_PREFIX]` autocorrections. The 9% hash-space cost (238,328 vs 262,144) is irrelevant for real files.
191
192
 
192
193
  Before hashing, each line is normalized: carriage returns are stripped and trailing whitespace is trimmed. This `canon()` normalization prevents insignificant whitespace changes from cascade-triggering hash churn across the file. Two lines that differ only in trailing spaces or `\r` characters produce the same hash, so anchor stability is preserved across editor-save cycles that add or remove trailing whitespace.
193
194
 
@@ -199,7 +200,7 @@ The 3-character space holds 238,328 unique anchors, so files are capped at 238,3
199
200
 
200
201
  ### Bare-prefix detector
201
202
 
202
- 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.
203
+ 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 strips the prefix from edit lines matching this pattern (with a warning) instead of writing the anchor into the file content.
203
204
 
204
205
  ## Development
205
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -2,7 +2,9 @@ import { abortIf, splitLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../u
2
2
  import { _lineHashesPure, HASH_SEP } from "./hash";
3
3
  import {
4
4
  valEdits,
5
- assertNoBarePrefix,
5
+ stripBarePrefixes,
6
+ stripDiffPrefixes,
7
+ swapReversedRanges,
6
8
  warnUnicodeEsc,
7
9
  fmtMismatch,
8
10
  descEdit,
@@ -246,8 +248,14 @@ export function applyEdits(
246
248
  const noopEdits: NEdit[] = [];
247
249
  const warnings: string[] = [];
248
250
 
251
+ const rangeFixed = swapReversedRanges(edits, fileHashes, warnings);
252
+ const prefixFixed = stripDiffPrefixes(
253
+ stripBarePrefixes(rangeFixed, fileHashes, warnings),
254
+ warnings,
255
+ );
256
+
249
257
  const { resolved: initialResolved, mismatches, boundaryWarnings } = valEdits(
250
- edits,
258
+ prefixFixed,
251
259
  lineIndex.fileLines,
252
260
  fileHashes,
253
261
  warnings,
@@ -259,14 +267,13 @@ export function applyEdits(
259
267
  );
260
268
  }
261
269
 
262
- assertNoBarePrefix(edits, lineIndex.fileLines, fileHashes);
263
- warnUnicodeEsc(edits, warnings);
270
+ warnUnicodeEsc(prefixFixed, warnings);
264
271
 
265
272
  let resolved = initialResolved;
266
273
  let autoFixes: AutoFix[] | undefined;
267
274
  if (boundaryWarnings.length > 0) {
268
275
  autoFixes = [];
269
- const correctedEdits: HEdit[] = edits.map(e => ({
276
+ const correctedEdits: HEdit[] = prefixFixed.map(e => ({
270
277
  ...e,
271
278
  content_lines: [...e.content_lines],
272
279
  }));
@@ -48,7 +48,6 @@ export const HL_PREFIX_PLUS_RE = new RegExp(
48
48
  export const HL_PREFIX_MINUS_RE = new RegExp(
49
49
  `^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
50
50
  );
51
- export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
52
51
 
53
52
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
54
53
 
@@ -7,7 +7,6 @@ export {
7
7
  MAX_HASH_LINES,
8
8
  HL_PREFIX_PLUS_RE,
9
9
  HL_PREFIX_MINUS_RE,
10
- DIFF_MINUS_RE,
11
10
  HL_BARE_PREFIX_RE,
12
11
  lineHashes,
13
12
  _lineHashesPure,
@@ -31,7 +30,9 @@ export {
31
30
  descEdit,
32
31
  resEdits,
33
32
  valEdits,
34
- assertNoBarePrefix,
33
+ stripBarePrefixes,
34
+ stripDiffPrefixes,
35
+ swapReversedRanges,
35
36
  fmtMismatch,
36
37
  } from "./resolve";
37
38
  export {
@@ -1,12 +1,8 @@
1
1
  import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
- HL_PREFIX_PLUS_RE,
5
- HL_PREFIX_MINUS_RE,
6
- DIFF_MINUS_RE,
7
4
  } from "./hash";
8
5
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
9
- import { clipLine } from "../utils";
10
6
 
11
7
  export type Anchor = { hash: string };
12
8
 
@@ -43,21 +39,6 @@ function parseRef(ref: string): Anchor {
43
39
 
44
40
  export const parseHashRef = parseRef;
45
41
 
46
- function assertNoPrefixes(lines: string[]): void {
47
- for (const line of lines) {
48
- if (!line.length) continue;
49
- if (
50
- HL_PREFIX_PLUS_RE.test(line) ||
51
- HL_PREFIX_MINUS_RE.test(line) ||
52
- DIFF_MINUS_RE.test(line)
53
- ) {
54
- throw new Error(
55
- `[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(clipLine(line))}. Use literal file content only — plain + or - lines are written literally.`
56
- );
57
- }
58
- }
59
- }
60
-
61
42
  export function parseText(edit: string[] | string | null): string[] {
62
43
  if (edit === null) {
63
44
  throw new Error('[E_BAD_SHAPE] "content_lines" must be a string array; use [] to delete a range.');
@@ -65,6 +46,5 @@ export function parseText(edit: string[] | string | null): string[] {
65
46
  if (typeof edit === "string") {
66
47
  throw new Error(CONTENT_LINES_NOT_STRING_MSG);
67
48
  }
68
- assertNoPrefixes(edit);
69
49
  return edit;
70
50
  }
@@ -1,5 +1,5 @@
1
1
  import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
2
- import { HL_BARE_PREFIX_RE } from "./hash";
2
+ import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
5
5
 
@@ -213,39 +213,98 @@ function warnUnicodeEsc(
213
213
  }
214
214
  }
215
215
 
216
- export function assertNoBarePrefix(
217
- edits: HEdit[],
218
- fileLines: string[],
219
- fileHashes: string[],
220
- ): void {
221
- const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
222
- for (let editIndex = 0; editIndex < edits.length; editIndex++) {
223
- const edit = edits[editIndex]!;
224
- for (let lineIndex = 0; lineIndex < edit.content_lines.length; lineIndex++) {
225
- const line = edit.content_lines[lineIndex]!;
226
- const match = line.match(HL_BARE_PREFIX_RE);
227
- if (match) suspects.push({ line, hash: match[1]!, editIndex, lineIndex });
228
- }
229
- }
230
- if (suspects.length === 0) return;
231
- const locations = suspects
232
- .map((s) => `edit ${s.editIndex}, content_lines[${s.lineIndex}]`)
233
- .join("; ");
234
-
235
- const fileHashSet = new Set(fileHashes);
236
- const matched = suspects.filter((s) => fileHashSet.has(s.hash));
237
- const matchedCount = matched.length;
238
-
239
- const exampleLine = `${suspects[0]!.hash}│${clipLine(suspects[0]!.line)}`;
216
+ export function stripBarePrefixes(
217
+ edits: HEdit[],
218
+ fileHashes: string[],
219
+ warnings: string[],
220
+ ): HEdit[] {
221
+ const fileHashSet = new Set(fileHashes);
222
+ let changed = false;
223
+ const corrected = edits.map((edit, editIndex) => {
224
+ const stripped: { lineIndex: number; matched: boolean }[] = [];
225
+ const contentLines = edit.content_lines.map((line, lineIndex) => {
226
+ const match = line.match(HL_BARE_PREFIX_RE);
227
+ if (!match) return line;
228
+ stripped.push({ lineIndex, matched: fileHashSet.has(match[1]!) });
229
+ return line.slice(match[0].length);
230
+ });
231
+ if (stripped.length === 0) return edit;
232
+ changed = true;
233
+ const locations = stripped
234
+ .map((s) => `content_lines[${s.lineIndex}]`)
235
+ .join(", ");
236
+ const matchedCount = stripped.filter((s) => s.matched).length;
237
+ const evidence =
238
+ matchedCount === 0
239
+ ? "none of the stripped hashes match current file lines"
240
+ : `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
241
+ warnings.push(
242
+ `Autocorrected edit ${editIndex}: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
243
+ );
244
+ return { ...edit, content_lines: contentLines };
245
+ });
246
+ return changed ? corrected : edits;
247
+ }
240
248
 
241
- const linesHint =
242
- matchedCount === 0
243
- ? `None match file line hashes.`
244
- : `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
249
+ export function stripDiffPrefixes(
250
+ edits: HEdit[],
251
+ warnings: string[],
252
+ ): HEdit[] {
253
+ let changed = false;
254
+ const corrected = edits.map((edit, editIndex) => {
255
+ const stripped: number[] = [];
256
+ const contentLines = edit.content_lines.map((line, lineIndex) => {
257
+ const plus = line.match(HL_PREFIX_PLUS_RE);
258
+ if (plus) {
259
+ stripped.push(lineIndex);
260
+ return line.slice(plus[0].length);
261
+ }
262
+ const minus = line.match(HL_PREFIX_MINUS_RE);
263
+ if (minus) {
264
+ stripped.push(lineIndex);
265
+ return line.slice(minus[0].length);
266
+ }
267
+ return line;
268
+ });
269
+ if (stripped.length === 0) return edit;
270
+ changed = true;
271
+ const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
272
+ warnings.push(
273
+ `Autocorrected edit ${editIndex}: stripped diff-preview marker copied from the diff preview in ${locations}.`
274
+ );
275
+ return { ...edit, content_lines: contentLines };
276
+ });
277
+ return changed ? corrected : edits;
278
+ }
245
279
 
246
- throw new Error(
247
- `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember: content_lines uses file content only, hash_range_inclusive uses hash anchors.`
248
- );
280
+ export function swapReversedRanges(
281
+ edits: HEdit[],
282
+ fileHashes: string[],
283
+ warnings: string[],
284
+ ): HEdit[] {
285
+ const lineByHash = new Map<string, number>();
286
+ for (let i = 0; i < fileHashes.length; i++) {
287
+ lineByHash.set(fileHashes[i]!, i + 1);
288
+ }
289
+ let changed = false;
290
+ const corrected = edits.map((edit, editIndex) => {
291
+ const [startRef, endRef] = edit.hash_range_inclusive;
292
+ const startLine = lineByHash.get(startRef.hash);
293
+ const endLine = lineByHash.get(endRef.hash);
294
+ if (
295
+ startLine === undefined ||
296
+ endLine === undefined ||
297
+ startLine <= endLine
298
+ ) {
299
+ return edit;
300
+ }
301
+ changed = true;
302
+ warnings.push(
303
+ `Autocorrected edit ${editIndex}: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
304
+ );
305
+ return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
306
+ });
307
+ return changed ? corrected : edits;
249
308
  }
250
309
 
251
310
  export function descEdit(edit: RHEdit): string {
package/src/replace.ts CHANGED
@@ -175,7 +175,9 @@ function collectRemovedHashes(
175
175
  const startLine = originalHashes.indexOf(startHash);
176
176
  const endLine = originalHashes.indexOf(endHash);
177
177
  if (startLine >= 0 && endLine >= 0) {
178
- for (let i = startLine; i <= endLine; i++) {
178
+ const firstLine = Math.min(startLine, endLine);
179
+ const lastLine = Math.max(startLine, endLine);
180
+ for (let i = firstLine; i <= lastLine; i++) {
179
181
  removedHashes.add(originalHashes[i]!);
180
182
  }
181
183
  }
@@ -197,7 +199,7 @@ function countLineChanges(
197
199
  const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
198
200
  const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
199
201
  if (startLine >= 0 && endLine >= 0) {
200
- totalRemovedLines += endLine - startLine + 1;
202
+ totalRemovedLines += Math.abs(endLine - startLine) + 1;
201
203
  }
202
204
  totalAddedLines += edit.content_lines.length;
203
205
  }