pi-hashline-edit-pro 2.6.3 → 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.
@@ -97,7 +97,7 @@ Notes:
97
97
 
98
98
  - The request is checked before any file I/O, so a bad request never touches the file.
99
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 exact payload; any applied edit clears it.
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.
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.3",
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",
@@ -1,3 +1,25 @@
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
+
1
23
  const boundaryBypassTracker = new Map<string, string>();
2
24
 
3
25
  export function noopPayloadKey(
@@ -6,7 +28,12 @@ export function noopPayloadKey(
6
28
  removeTo: string,
7
29
  replacementLines: string[],
8
30
  ): string {
9
- return JSON.stringify([absolutePath, removeFrom, removeTo, replacementLines]);
31
+ return JSON.stringify([
32
+ absolutePath,
33
+ canonRef(removeFrom),
34
+ canonRef(removeTo),
35
+ canonLines(replacementLines),
36
+ ]);
10
37
  }
11
38
 
12
39
  export function markBoundaryNoop(absolutePath: string, payload: string): void {
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
  }
@@ -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
 
@@ -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_RUN})│`);
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