pi-hashline-edit-pro 2.6.2 → 2.6.4

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
@@ -66,7 +66,7 @@ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with
66
66
  Edge cases:
67
67
 
68
68
  - Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
69
- - Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text.
69
+ - Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text. The NUL-byte check covers the whole file, not just the sampled bytes: a file with a NUL byte anywhere is rejected as binary.
70
70
  - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
71
71
  - Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
72
72
  - BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
@@ -96,8 +96,8 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
96
96
  Notes:
97
97
 
98
98
  - The request is checked before any file I/O, so a bad request never touches the file.
99
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
100
- - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
99
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
100
+ - An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the exact same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally — the duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `HASH│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
101
101
  - Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
102
102
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
103
103
  - Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
@@ -181,6 +181,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
181
181
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
182
182
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
183
183
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
184
+ | `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
184
185
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
185
186
 
186
187
  ## Troubleshooting
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "./src/config";
15
15
  import { loadHashStore, pruneMissing } from "./src/hash-store";
16
16
  import { recordServedSafe, clearServed } from "./src/served";
17
+ import { clearBoundaryBypass } from "./src/boundary-bypass";
17
18
  import { readNormFile } from "./src/file-reader";
18
19
  import { loadFileKindAndText } from "./src/file-kind";
19
20
  import { toCwd } from "./src/paths";
@@ -64,6 +65,7 @@ export default function (pi: ExtensionAPI): void {
64
65
  try {
65
66
  const target = await resolveTarget(toCwd(writtenPath, ctx.cwd));
66
67
  await clearUndo(target);
68
+ clearBoundaryBypass(target);
67
69
  const store = await loadHashStore();
68
70
  clearServed(store, target);
69
71
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -0,0 +1,53 @@
1
+ import { parseText } from "./hashline/parse";
2
+ import { ANCHOR_ROW_RE } from "./hashline/resolve";
3
+ import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hashline/hash";
4
+
5
+ function canonRef(ref: string): string {
6
+ const trimmed = ref.trim();
7
+ const match = trimmed.match(ANCHOR_ROW_RE);
8
+ return match ? match[2]! : trimmed;
9
+ }
10
+
11
+ function canonLines(lines: string[]): string[] {
12
+ return parseText(lines).map((line) => {
13
+ const bare = line.match(HL_BARE_PREFIX_RE);
14
+ if (bare) return line.slice(bare[0].length);
15
+ const plus = line.match(HL_PREFIX_PLUS_RE);
16
+ if (plus) return line.slice(plus[0].length);
17
+ const minus = line.match(HL_PREFIX_MINUS_RE);
18
+ if (minus) return line.slice(minus[0].length);
19
+ return line;
20
+ });
21
+ }
22
+
23
+ const boundaryBypassTracker = new Map<string, string>();
24
+
25
+ export function noopPayloadKey(
26
+ absolutePath: string,
27
+ removeFrom: string,
28
+ removeTo: string,
29
+ replacementLines: string[],
30
+ ): string {
31
+ return JSON.stringify([
32
+ absolutePath,
33
+ canonRef(removeFrom),
34
+ canonRef(removeTo),
35
+ canonLines(replacementLines),
36
+ ]);
37
+ }
38
+
39
+ export function markBoundaryNoop(absolutePath: string, payload: string): void {
40
+ boundaryBypassTracker.set(absolutePath, payload);
41
+ }
42
+
43
+ export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
44
+ if (boundaryBypassTracker.get(absolutePath) === payload) {
45
+ boundaryBypassTracker.delete(absolutePath);
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ export function clearBoundaryBypass(absolutePath: string): void {
52
+ boundaryBypassTracker.delete(absolutePath);
53
+ }
package/src/file-kind.ts CHANGED
@@ -125,6 +125,7 @@ export async function loadFileKindAndText(
125
125
 
126
126
  const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
127
127
  let hadUtf8DecodeErrors = false;
128
+ let containsNul = false;
128
129
  let newlineCount = 0;
129
130
  const parts: string[] = [];
130
131
 
@@ -133,6 +134,9 @@ export async function loadFileKindAndText(
133
134
  if (!hadUtf8DecodeErrors && decoded.includes("\uFFFD")) {
134
135
  hadUtf8DecodeErrors = true;
135
136
  }
137
+ if (!containsNul && decoded.includes("\0")) {
138
+ containsNul = true;
139
+ }
136
140
  if (options?.maxLines !== undefined) {
137
141
  for (let i = 0; i < decoded.length; i++) {
138
142
  if (decoded.charCodeAt(i) === 10) newlineCount++;
@@ -166,6 +170,10 @@ export async function loadFileKindAndText(
166
170
  }
167
171
  parts.push(decodeChunk(new Uint8Array(0), false));
168
172
 
173
+ if (containsNul) {
174
+ return { kind: "binary", description: "contains NUL bytes" };
175
+ }
176
+
169
177
  return {
170
178
  kind: "text",
171
179
  text: parts.join(""),
package/src/fs-write.ts CHANGED
@@ -142,7 +142,7 @@ export async function writeAtomic(
142
142
  }
143
143
  await tempHandle.sync();
144
144
  } catch (error: unknown) {
145
- await tempHandle.close();
145
+ try { await tempHandle.close(); } catch {}
146
146
  try { await rm(tempPath, { force: true }); } catch {}
147
147
  throw error;
148
148
  }
@@ -9,4 +9,6 @@ export const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
9
9
 
10
10
  export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
11
11
 
12
+ export const HASH_RUN = `[${ALPH_SAFE}]{1,${HASH_LEN * 2}}`;
13
+
12
14
  export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
@@ -84,11 +84,19 @@ function resToSpan(
84
84
  }
85
85
 
86
86
  if (edit.content_lines.length > 0) {
87
+ const lastReplacementLine = edit.content_lines[edit.content_lines.length - 1]!;
88
+ const endsWithBlank = lastReplacementLine.length === 0;
89
+ const endsAtEofWithoutNewline =
90
+ endLine === fileLines.length && !content.endsWith("\n");
91
+ const replacement = edit.content_lines.join("\n");
87
92
  return {
88
93
  kind: "replace",
89
94
  start: lineStarts[startLine - 1]!,
90
95
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
91
- replacement: edit.content_lines.join("\n"),
96
+ replacement:
97
+ endsAtEofWithoutNewline && endsWithBlank
98
+ ? `${replacement}\n`
99
+ : replacement,
92
100
  };
93
101
  }
94
102
 
@@ -147,6 +155,7 @@ export function applyEdit(
147
155
  precomputedHashes?: string[],
148
156
  filePath?: string,
149
157
  servedHashes?: ReadonlySet<string>,
158
+ skipBoundaryDedup?: boolean,
150
159
  ): {
151
160
  content: string;
152
161
  firstChangedLine: number | undefined;
@@ -188,7 +197,7 @@ export function applyEdit(
188
197
 
189
198
  let resolved = initialResolved;
190
199
  let autoFixes: AutoFix[] | undefined;
191
- if (boundaryDups.length > 0) {
200
+ if (boundaryDups.length > 0 && !skipBoundaryDedup) {
192
201
  autoFixes = [];
193
202
  const correctedEdit: HEdit = {
194
203
  ...prefixFixed,
@@ -241,6 +250,7 @@ export function applyEdit(
241
250
  firstChangedLine: undefined,
242
251
  lastChangedLine: undefined,
243
252
  ...(warnings.length ? { warnings } : {}),
253
+ ...(autoFixes ? { autoFixes } : {}),
244
254
  noopEdit: { loc: spanResult.loc, currentContent: spanResult.currentContent },
245
255
  };
246
256
  }
@@ -6,8 +6,8 @@ import {
6
6
  upsertSnapshot,
7
7
  } from "../hash-store";
8
8
  import { xxh32, contentChecksum, initHasher } from "./hasher";
9
- import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS } from "./alphabet";
10
- export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS };
9
+ import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
10
+ export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
11
11
 
12
12
  export const ANCHOR_LEN = HASH_LEN;
13
13
 
@@ -39,13 +39,13 @@ function hashAt(idx: number): string {
39
39
  }
40
40
 
41
41
  export const HL_PREFIX_PLUS_RE = new RegExp(
42
- `^\\+${HASH_CLASS}│`,
42
+ `^\\+${HASH_RUN}│`,
43
43
  );
44
44
  export const HL_PREFIX_MINUS_RE = new RegExp(
45
- `^-(?:${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
45
+ `^-(?:${HASH_RUN}│| {${ANCHOR_LEN}}│)`,
46
46
  );
47
47
 
48
- export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
48
+ export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
49
49
 
50
50
  export function canon(line: string): string {
51
51
  return line.replace(/\r/g, "").trimEnd();
@@ -3,6 +3,7 @@ export {
3
3
  ANCHOR_LEN,
4
4
  HASH_SEP,
5
5
  HASH_CLASS,
6
+ HASH_RUN,
6
7
  HASH_SPACE,
7
8
  HASH_PROBE_STRIDE,
8
9
  MAX_HASH_LINES,
@@ -1,5 +1,5 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
- import { HASH_CLASS, HASH_SEP, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
2
+ import { HASH_SEP, HASH_RUN, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
5
 
@@ -162,7 +162,7 @@ function assertItem(edit: Record<string, unknown>): void {
162
162
  }
163
163
  }
164
164
 
165
- const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
165
+ export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
166
166
 
167
167
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
168
168
  assertItem(edit as Record<string, unknown>);
package/src/prompts.ts CHANGED
@@ -1,23 +1,11 @@
1
1
  import { readFileSync } from "fs";
2
2
 
3
- export function loadP(relativePath: string, replacements?: Record<string, string>): string {
4
- let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
5
- if (replacements) {
6
- for (const [key, value] of Object.entries(replacements)) {
7
- content = content.split(`{{${key}}}`).join(value);
8
- }
9
- }
10
- return content;
3
+ export function loadP(relativePath: string): string {
4
+ return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
11
5
  }
12
6
 
13
- export function loadGuide(relativePath: string, replacements?: Record<string, string>): string[] {
14
- let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
15
- if (replacements) {
16
- for (const [key, value] of Object.entries(replacements)) {
17
- content = content.split(`{{${key}}}`).join(value);
18
- }
19
- }
20
- return content
7
+ export function loadGuide(relativePath: string): string[] {
8
+ return readFileSync(new URL(relativePath, import.meta.url), "utf-8")
21
9
  .split("\n")
22
10
  .map((line) => line.trim())
23
11
  .filter((line) => line.startsWith("- "))
@@ -50,14 +50,19 @@ export function getPreviewInput(
50
50
  return request;
51
51
  }
52
52
 
53
+ type DiffRowKind = "added" | "removed" | "context";
54
+
55
+ function diffRowKind(line: string): DiffRowKind {
56
+ if (line.startsWith("+") && !line.startsWith("+++")) return "added";
57
+ if (line.startsWith("-") && !line.startsWith("---")) return "removed";
58
+ return "context";
59
+ }
60
+
53
61
  export function colorLines(lines: string[], theme: FgT): string[] {
54
62
  return lines.map((line) => {
55
- if (line.startsWith("+") && !line.startsWith("+++")) {
56
- return theme.fg("success", line);
57
- }
58
- if (line.startsWith("-") && !line.startsWith("---")) {
59
- return theme.fg("error", line);
60
- }
63
+ const kind = diffRowKind(line);
64
+ if (kind === "added") return theme.fg("success", line);
65
+ if (kind === "removed") return theme.fg("error", line);
61
66
  return theme.fg("dim", line);
62
67
  });
63
68
  }
@@ -194,12 +199,9 @@ export function mkMdTheme(theme: MdTheme) {
194
199
  highlightCode: (code: string, lang?: string) =>
195
200
  code.split("\n").map((line) => {
196
201
  if (lang === "diff") {
197
- if (line.startsWith("+") && !line.startsWith("+++")) {
198
- return theme.fg("toolDiffAdded", line);
199
- }
200
- if (line.startsWith("-") && !line.startsWith("---")) {
201
- return theme.fg("toolDiffRemoved", line);
202
- }
202
+ const kind = diffRowKind(line);
203
+ if (kind === "added") return theme.fg("toolDiffAdded", line);
204
+ if (kind === "removed") return theme.fg("toolDiffRemoved", line);
203
205
  return theme.fg("toolDiffContext", line);
204
206
  }
205
207
 
package/src/replace.ts CHANGED
@@ -47,6 +47,7 @@ import { loadP, loadGuide } from "./prompts";
47
47
  import { saveUndo } from "./replace-undo";
48
48
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
49
49
  import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
50
+ import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
50
51
 
51
52
  const replacementLinesSchema = Type.Array(
52
53
  Type.String({
@@ -107,6 +108,7 @@ interface PipelineResult {
107
108
  resultHashes: string[];
108
109
  totalAddedLines: number;
109
110
  totalRemovedLines: number;
111
+ hadBoundaryDedup: boolean;
110
112
  }
111
113
 
112
114
  const PREVIEW_DEBOUNCE_MS = 150;
@@ -179,6 +181,7 @@ export interface ExecPipelineOptions {
179
181
  signal?: AbortSignal;
180
182
  store?: HashStore;
181
183
  noPersist?: boolean;
184
+ skipBoundaryDedup?: boolean;
182
185
  }
183
186
 
184
187
  function collectRemovedHashes(
@@ -252,6 +255,7 @@ export async function execPipeline(
252
255
  originalHashes,
253
256
  path,
254
257
  served,
258
+ options?.skipBoundaryDedup,
255
259
  );
256
260
  } catch (error) {
257
261
  if (options?.noPersist !== true) {
@@ -298,6 +302,7 @@ export async function execPipeline(
298
302
  originalHashes,
299
303
  totalAddedLines,
300
304
  totalRemovedLines,
305
+ hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
301
306
  };
302
307
  }
303
308
 
@@ -477,6 +482,8 @@ export function buildToolDef(): ToolDef {
477
482
  const path = normalizedParams.path;
478
483
  const absolutePath = toCwd(path, ctx.cwd);
479
484
  const mutationTargetPath = await resolveTarget(absolutePath);
485
+ const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
486
+ const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
480
487
  return withFileMutationQueue(mutationTargetPath, async () => {
481
488
  abortIf(signal);
482
489
 
@@ -492,21 +499,28 @@ export function buildToolDef(): ToolDef {
492
499
  firstChangedLine,
493
500
  lastChangedLine,
494
501
  resultHashes,
502
+ hadBoundaryDedup,
495
503
  totalAddedLines,
496
504
  totalRemovedLines,
497
505
  } = await execPipeline(
498
506
  normalizedParams,
499
507
  ctx.cwd,
500
- { accessMode: constants.R_OK | constants.W_OK, signal },
508
+ { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
501
509
  );
502
510
 
503
511
  if (resolution) {
504
512
  warnings.unshift(resolution.warning);
505
513
  }
514
+ if (boundaryBypass && originalNormalized !== result) {
515
+ warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call. Boundary dedup is now restored.");
516
+ }
506
517
 
507
518
  const editsAttempted = 1;
508
519
  if (originalNormalized === result) {
509
520
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
521
+ if (hadBoundaryDedup) {
522
+ markBoundaryNoop(mutationTargetPath, noopPayload);
523
+ }
510
524
  return buildNoop({
511
525
  path,
512
526
  noopEdit,
@@ -550,6 +564,7 @@ export function buildToolDef(): ToolDef {
550
564
  await undo.restore();
551
565
  throw error;
552
566
  }
567
+ clearBoundaryBypass(mutationTargetPath);
553
568
  const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
554
569
 
555
570
  const editMeta: RMeta = {