pi-hashline-edit-pro 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,7 +121,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
121
121
  Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
122
122
 
123
123
  - A no-op `replace` produces no diff — the file is unchanged, so existing anchors remain valid.
124
- - After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — call `read` for fresh anchors.
124
+ - After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — the diff rows themselves are the fresh anchors (`+HASH│` and ` HASH│` rows carry the current hashes, and unchanged lines keep their previous hashes), so follow-up edits can anchor on the diff directly; call `read` when you want the full file's anchors.
125
125
  - With auto-read disabled, `replace` / `undo_last_replace` results keep the plain summary in the model-visible text — no diff and no anchor block reach the model (the post-edit diff is still shown to the user).
126
126
  - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
127
127
  - Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
@@ -179,7 +179,7 @@ Each line is canonicalized (carriage returns stripped, trailing whitespace trimm
179
179
 
180
180
  The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so 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, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
181
181
 
182
- **Unique anchors by construction.** If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset (O(1) amortized). Every line in a file therefore gets a unique anchor — two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
182
+ **Unique anchors by construction.** If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions — runs of blank lines, repeated `}` — land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor — two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
183
183
 
184
184
  ## Design decisions
185
185
 
@@ -193,8 +193,9 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
193
193
 
194
194
  - **Stale anchors.** `[E_STALE_ANCHOR]` / `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read, or an earlier `read` never happened. Call `read` for fresh anchors and retry.
195
195
  - **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
196
+ - **Upgrading.** A hash-allocation change clears the hash store once on the first run after upgrade — anchors are rebuilt on the next read and undo history is lost, but no project files are touched.
196
197
  - **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
197
- - **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
198
+ - **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted. Legacy snapshots containing duplicate hashes are skipped and rebuilt on the next read.
198
199
  - **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
199
200
 
200
201
  ## Development
package/index.ts CHANGED
@@ -7,6 +7,7 @@ import { regRead, fmtReadPreview } from "./src/read";
7
7
  import type { RMetrics } from "./src/replace-response";
8
8
  import { extractWarnings } from "./src/replace-render";
9
9
  import { MAX_HASH_LINES } from "./src/hashline";
10
+ import { AUTO_READ_MAX } from "./src/constants";
10
11
  import {
11
12
  readConfig,
12
13
  toggleAutoRead,
@@ -81,6 +82,7 @@ export default function (pi: ExtensionAPI): void {
81
82
  fileHashes,
82
83
  absolutePath,
83
84
  DEFAULT_MAX_BYTES,
85
+ AUTO_READ_MAX,
84
86
  );
85
87
  return {
86
88
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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",
@@ -1,2 +1,2 @@
1
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.
2
+ - `read`: call again after any edit to that file — changed lines get new anchors (the post-edit diff rows from replace/undo also carry fresh anchors for the changed range).
@@ -4,3 +4,4 @@
4
4
  - `replace`: to replace a single line, repeat its hash in both positions of hash_range_inclusive: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
5
5
  - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
6
  - `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
7
+ - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
@@ -1 +1,2 @@
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.
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.
2
+ - `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
package/src/constants.ts CHANGED
@@ -4,7 +4,7 @@ export const MAX_BYTES = 100 * 1024 * 1024;
4
4
  export const MAX_READ_LINE_BYTES = 200 * 1024;
5
5
 
6
6
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
7
- export const HASH_STORE_VERSION = 4;
7
+ export const HASH_STORE_VERSION = 5;
8
8
  export const CONTENT_LINES_NOT_STRING_MSG =
9
9
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
10
10
  + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
package/src/hash-store.ts CHANGED
@@ -318,6 +318,10 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
318
318
  const rows: [string, string, number, string, number][] = [];
319
319
  for (const [key, value] of Object.entries(raw)) {
320
320
  if (!isValidSnapshot(value)) continue;
321
+ if (new Set(value.hashes).size !== value.hashes.length) {
322
+ console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
323
+ continue;
324
+ }
321
325
  rows.push([
322
326
  key,
323
327
  contentChecksum(value.content),
@@ -22,6 +22,8 @@ export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
22
22
  export const HASH_SPACE = ALPH.length ** HASH_LEN;
23
23
  export const MAX_HASH_LINES = HASH_SPACE;
24
24
 
25
+ export const HASH_PROBE_STRIDE = ALPH.length ** 2 + ALPH.length + 1;
26
+
25
27
  function idxToHash(idx: number): string {
26
28
  let out = "";
27
29
  for (let j = 0; j < HASH_LEN; j++) {
@@ -66,44 +68,13 @@ function setBit(bits: Uint32Array, idx: number): void {
66
68
  }
67
69
 
68
70
  function nextZeroBit(bits: Uint32Array, start: number): number {
69
- const totalWords = bits.length;
70
71
  const totalBits = HASH_SPACE;
71
- const lastWordBits = HASH_SPACE - (totalWords - 1) * 32;
72
-
73
- if (start >= totalBits) start = 0;
74
-
75
- const wordIdx = start >>> 5;
76
- const bitOffset = start & 31;
77
- const wordBits = (w: number): number => (w === totalWords - 1 ? lastWordBits : 32);
78
-
79
- let word = bits[wordIdx];
80
- for (let b = bitOffset; b < wordBits(wordIdx); b++) {
81
- if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
82
- }
83
-
84
- for (let w = wordIdx + 1; w < totalWords; w++) {
85
- word = bits[w];
86
- if (~word !== 0) {
87
- for (let b = 0; b < wordBits(w); b++) {
88
- if ((word >>> b & 1) === 0) return w * 32 + b;
89
- }
90
- }
72
+ let idx = start % totalBits;
73
+ for (let i = 0; i < totalBits; i++) {
74
+ if (!getBit(bits, idx)) return idx;
75
+ idx += HASH_PROBE_STRIDE;
76
+ if (idx >= totalBits) idx -= totalBits;
91
77
  }
92
-
93
- for (let w = 0; w < wordIdx; w++) {
94
- word = bits[w];
95
- if (~word !== 0) {
96
- for (let b = 0; b < wordBits(w); b++) {
97
- if ((word >>> b & 1) === 0) return w * 32 + b;
98
- }
99
- }
100
- }
101
-
102
- word = bits[wordIdx];
103
- for (let b = 0; b < bitOffset; b++) {
104
- if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
105
- }
106
-
107
78
  throw new Error(
108
79
  `[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.`,
109
80
  );
@@ -112,13 +83,12 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
112
83
  function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
113
84
  if (!getBit(used, baseIdx)) {
114
85
  setBit(used, baseIdx);
115
- hint.value = baseIdx + 1;
86
+ hint.value = baseIdx + HASH_PROBE_STRIDE;
116
87
  return hashAt(baseIdx);
117
88
  }
118
- const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
119
- const nextIdx = nextZeroBit(used, start);
89
+ const nextIdx = nextZeroBit(used, hint.value);
120
90
  setBit(used, nextIdx);
121
- hint.value = nextIdx + 1;
91
+ hint.value = nextIdx + HASH_PROBE_STRIDE;
122
92
  return hashAt(nextIdx);
123
93
  }
124
94
 
@@ -277,7 +247,7 @@ function mapStableHashes(
277
247
  const idx = hashToIndex(hash);
278
248
  if (idx >= 0) {
279
249
  setBit(used, idx);
280
- if (idx + 1 > hint.value) hint.value = idx + 1;
250
+ if (idx + HASH_PROBE_STRIDE > hint.value) hint.value = idx + HASH_PROBE_STRIDE;
281
251
  }
282
252
  };
283
253
 
@@ -4,6 +4,7 @@ export {
4
4
  HASH_SEP,
5
5
  HASH_CLASS,
6
6
  HASH_SPACE,
7
+ HASH_PROBE_STRIDE,
7
8
  MAX_HASH_LINES,
8
9
  HL_PREFIX_PLUS_RE,
9
10
  HL_PREFIX_MINUS_RE,
@@ -224,8 +224,12 @@ export function stripBarePrefixes(
224
224
  matchedCount === 0
225
225
  ? "none of the stripped hashes match current file lines"
226
226
  : `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
227
+ const guidance =
228
+ matchedCount === 0
229
+ ? " Verify that these lines were pasted from read output; literal content starting with 'HASH│' would be altered by this strip."
230
+ : "";
227
231
  warnings.push(
228
- `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
232
+ `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).${guidance}`
229
233
  );
230
234
  return { ...edit, content_lines: contentLines };
231
235
  }
package/src/read.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  createReadTool,
4
4
  formatSize,
5
5
  truncateHead,
6
+ DEFAULT_MAX_LINES,
6
7
  type TruncationResult,
7
8
  } from "@earendil-works/pi-coding-agent";
8
9
  import { Type } from "typebox";
@@ -57,6 +58,7 @@ export async function fmtReadPreview(
57
58
  precomputedHashes?: string[],
58
59
  path?: string,
59
60
  maxLineBytes = MAX_READ_LINE_BYTES,
61
+ maxTruncLines = DEFAULT_MAX_LINES,
60
62
  ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
61
63
  const allLines = visLines(text);
62
64
  const totalLines = allLines.length;
@@ -99,7 +101,7 @@ export async function fmtReadPreview(
99
101
  ? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
100
102
  : fmtRegion([selectedHashes[index]!], [selected[index]!]),
101
103
  );
102
- const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
104
+ const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
103
105
  const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
104
106
  const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
105
107
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
@@ -121,7 +123,7 @@ export async function fmtReadPreview(
121
123
  };
122
124
  }
123
125
 
124
- const truncation = truncateHead(formatted, { maxBytes });
126
+ const truncation = truncateHead(formatted, { maxBytes, maxLines: maxTruncLines });
125
127
 
126
128
  let preview = truncation.content;
127
129
  let nextOffset: number | undefined;
package/src/replace.ts CHANGED
@@ -190,16 +190,16 @@ export async function execPipeline(
190
190
 
191
191
  const path = params.path;
192
192
 
193
- const hashStore = options?.store ?? await loadHashStore();
193
+ const edit = resEdit({
194
+ hash_range_inclusive: params.hash_range_inclusive,
195
+ content_lines: params.content_lines,
196
+ });
194
197
 
198
+ const hashStore = options?.store ?? await loadHashStore();
195
199
  const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
196
200
  path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore },
197
201
  );
198
202
 
199
- const edit = resEdit({
200
- hash_range_inclusive: params.hash_range_inclusive,
201
- content_lines: params.content_lines,
202
- });
203
203
  const anchorResult = applyEdit(
204
204
  originalNormalized,
205
205
  edit,