pi-hashline-edit-pro 0.9.8 → 0.11.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/index.ts CHANGED
@@ -1,37 +1,37 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "fs/promises";
3
3
  import { join, isAbsolute } from "path";
4
- import { computeLineHashes, ensureHasherReady, formatHashlineRegion } from "./src/hashline";
5
- import { registerReplaceTool } from "./src/replace";
6
- import { registerReadTool } from "./src/read";
7
- import { normalizeToLF } from "./src/replace-diff";
8
- import { getVisibleLines } from "./src/utils";
9
- import { AUTO_READ_MAX_LINES } from "./src/constants";
4
+ import { lineHashes, initHasher, fmtRegion } from "./src/hashline";
5
+ import { regReplace } from "./src/replace";
6
+ import { regRead } from "./src/read";
7
+ import { toLF } from "./src/replace-diff";
8
+ import { visLines } from "./src/utils";
9
+ import { AUTO_READ_MAX } from "./src/constants";
10
10
 
11
11
  export default function (pi: ExtensionAPI): void {
12
- registerReadTool(pi);
13
- registerReplaceTool(pi);
12
+ regRead(pi);
13
+ regReplace(pi);
14
14
 
15
15
  pi.on("session_start", async (_event, ctx) => {
16
16
  const active = pi.getActiveTools();
17
17
  pi.setActiveTools(active.filter((t) => t !== "edit"));
18
- await ensureHasherReady();
18
+ await initHasher();
19
19
  });
20
20
 
21
21
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
22
- let autoReadEnabled = autoReadValue === "1" || autoReadValue === "true";
22
+ let autoRead = autoReadValue === "1" || autoReadValue === "true";
23
23
 
24
24
  pi.registerCommand("toggle-auto-read", {
25
25
  description: "Toggle automatic hashline anchors after write operations",
26
26
  handler: async (_args, ctx) => {
27
- autoReadEnabled = !autoReadEnabled;
28
- const state = autoReadEnabled ? "enabled" : "disabled";
27
+ autoRead = !autoRead;
28
+ const state = autoRead ? "enabled" : "disabled";
29
29
  ctx.ui.notify(`Auto-read after write: ${state}`, "info");
30
30
  },
31
31
  });
32
32
 
33
33
  pi.on("tool_result", async (event, ctx) => {
34
- if (!autoReadEnabled) return;
34
+ if (!autoRead) return;
35
35
  if (event.toolName !== "write" || event.isError) return;
36
36
 
37
37
  const filePath = (event.input as Record<string, unknown>)?.path;
@@ -41,20 +41,20 @@ export default function (pi: ExtensionAPI): void {
41
41
  const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
42
42
  const content = await readFile(absolutePath, "utf-8");
43
43
 
44
- const normalized = normalizeToLF(content);
45
- const visibleLines = getVisibleLines(normalized);
44
+ const normalized = toLF(content);
45
+ const visibleLines = visLines(normalized);
46
46
 
47
47
  if (visibleLines.length === 0) return;
48
48
 
49
- const truncated = visibleLines.length > AUTO_READ_MAX_LINES;
50
- const displayLines = truncated ? visibleLines.slice(0, AUTO_READ_MAX_LINES) : visibleLines;
49
+ const truncated = visibleLines.length > AUTO_READ_MAX;
50
+ const displayLines = truncated ? visibleLines.slice(0, AUTO_READ_MAX) : visibleLines;
51
51
 
52
- const hashes = computeLineHashes(normalized);
52
+ const hashes = lineHashes(normalized);
53
53
  const selectedHashes = hashes.slice(0, displayLines.length);
54
- const hashlineOutput = formatHashlineRegion(selectedHashes, displayLines);
54
+ const hashlineOutput = fmtRegion(selectedHashes, displayLines);
55
55
 
56
56
  const paginationHint = truncated
57
- ? `\n\n[Showing lines 1-${AUTO_READ_MAX_LINES} of ${visibleLines.length}. Use offset=${AUTO_READ_MAX_LINES + 1} to continue.]`
57
+ ? `\n\n[Showing lines 1-${AUTO_READ_MAX} of ${visibleLines.length}. Use offset=${AUTO_READ_MAX + 1} to continue.]`
58
58
  : "";
59
59
 
60
60
  if (hashlineOutput) {
@@ -75,4 +75,4 @@ export default function (pi: ExtensionAPI): void {
75
75
  ctx.ui.notify("Hashline Edit mode active", "info");
76
76
  });
77
77
  }
78
- }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.9.8",
3
+ "version": "0.11.0",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -41,11 +41,13 @@
41
41
  },
42
42
  "scripts": {
43
43
  "test": "vitest run",
44
- "test:watch": "vitest"
44
+ "test:watch": "vitest",
45
+ "typecheck": "tsc --noEmit"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@earendil-works/pi-coding-agent": "^0.74.0",
48
49
  "@types/node": "^22.0.0",
49
- "vitest": "^4.1.8"
50
+ "vitest": "^4.1.8",
51
+ "typescript": "^5.8.0"
50
52
  }
51
53
  }
@@ -48,6 +48,32 @@ Examples:
48
48
  ] }
49
49
  ```
50
50
 
51
+ ⚠️ Common mistake: do not copy the `HASH│` prefix into `new_lines`.
52
+
53
+ Wrong:
54
+ ```json
55
+ { "old_range": ["F4T", "F4T"], "new_lines": ["F4T│import { x } from \"./x\";"] }
56
+ ```
57
+
58
+ Right:
59
+ ```json
60
+ { "old_range": ["F4T", "F4T"], "new_lines": ["import { x } from \"./x\";"] }
61
+ ```
62
+
63
+ `old_range` uses the hash anchor. `new_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
64
+
65
+ ⚠️ Common mistake: `old_range` is only the 3-character HASH, not the full `HASH│content` line.
66
+
67
+ Wrong:
68
+ ```json
69
+ { "old_range": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "new_lines": [...] }
70
+ ```
71
+
72
+ Right:
73
+ ```json
74
+ { "old_range": ["F4T", "F4T"], "new_lines": [...] }
75
+ ```
76
+
51
77
  Rules:
52
78
  - `old_range` is a pair `[start, end]`. A single-line replace is `old_range: ["X", "X"]`.
53
79
  - To delete a range, use `new_lines: []`.
@@ -69,6 +95,6 @@ Error recovery:
69
95
  - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` format detected. Use `{old_range, new_lines}` instead.
70
96
  - `[E_EDIT_CONFLICT]` — two edits overlap on the same line range. Make edits non-overlapping.
71
97
  - `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
72
- - `[E_BARE_HASH_PREFIX]` — edit line starts with `HASH│`. Use literal file content in `new_lines`, not read output.
98
+ - `[E_BARE_HASH_PREFIX]` — a `new_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `old_range` uses hashes, `new_lines` does not.
73
99
  - `[E_INVALID_PATCH]` — diff prefixes (`+`/`-`) in `new_lines`. Use literal content only.
74
100
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
package/src/constants.ts CHANGED
@@ -1,6 +1,6 @@
1
- export const AUTO_READ_MAX_LINES = 2000;
2
- export const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
3
- export const ANCHOR_CONTEXT_LINES = 0;
4
- export const ANCHOR_MAX_OUTPUT_LINES = 12;
5
- export const FILE_TYPE_SNIFF_BYTES = 8192;
6
- export const MAX_FILE_BYTES = 100 * 1024 * 1024;
1
+ export const AUTO_READ_MAX = 2000;
2
+ export const ANCHOR_BUDGET = 50 * 1024;
3
+ export const CTX_LINES = 0;
4
+ export const MAX_OUT = 12;
5
+ export const SNIFF_BYTES = 8192;
6
+ export const MAX_BYTES = 100 * 1024 * 1024;
package/src/file-kind.ts CHANGED
@@ -1,43 +1,43 @@
1
1
  import { open as fsOpen, stat as fsStat } from "fs/promises";
2
2
  import { fileTypeFromBuffer } from "file-type";
3
- import { FILE_TYPE_SNIFF_BYTES, MAX_FILE_BYTES } from "./constants";
3
+ import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
 
5
- const IMAGE_MIME_TYPES = new Set<string>([
5
+ const IMG_TYPES = new Set<string>([
6
6
  "image/jpeg",
7
7
  "image/png",
8
8
  "image/gif",
9
9
  "image/webp",
10
10
  ]);
11
11
 
12
- const TEXT_LIKE_MIME_TYPES = new Set<string>([
12
+ const TEXT_TYPES = new Set<string>([
13
13
  "application/rtf",
14
14
  "application/xml",
15
15
  "application/x-ms-regedit",
16
16
  ]);
17
17
 
18
- function isTextLikeMimeType(mimeType: string): boolean {
19
- return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
18
+ function isTextType(mimeType: string): boolean {
19
+ return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
20
20
  }
21
21
 
22
- export type FileKind =
22
+ export type FKind =
23
23
  | { kind: "directory" }
24
24
  | { kind: "image"; mimeType: string }
25
25
  | { kind: "text" }
26
26
  | { kind: "binary"; description: string };
27
27
 
28
- export type LoadedFile =
28
+ export type LFile =
29
29
  | { kind: "directory" }
30
30
  | { kind: "image"; mimeType: string }
31
31
  | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
32
32
  | { kind: "binary"; description: string };
33
33
 
34
- function hasNullByte(buffer: Uint8Array): boolean {
34
+ function hasNull(buffer: Uint8Array): boolean {
35
35
  return buffer.includes(0);
36
36
  }
37
37
 
38
38
  export async function loadFileKindAndText(
39
39
  filePath: string,
40
- ): Promise<LoadedFile> {
40
+ ): Promise<LFile> {
41
41
  const pathStat = await fsStat(filePath);
42
42
  if (pathStat.isDirectory()) {
43
43
  return { kind: "directory" };
@@ -48,20 +48,20 @@ export async function loadFileKindAndText(
48
48
  description: "unsupported file type",
49
49
  };
50
50
  }
51
- if (pathStat.size > MAX_FILE_BYTES) {
51
+ if (pathStat.size > MAX_BYTES) {
52
52
  return {
53
53
  kind: "binary",
54
- description: `file exceeds ${MAX_FILE_BYTES} byte limit`
54
+ description: `file exceeds ${MAX_BYTES} byte limit`
55
55
  };
56
56
  }
57
57
 
58
58
  const fileHandle = await fsOpen(filePath, "r");
59
59
  try {
60
- const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
60
+ const buffer = Buffer.alloc(SNIFF_BYTES);
61
61
  const { bytesRead } = await fileHandle.read(
62
62
  buffer,
63
63
  0,
64
- FILE_TYPE_SNIFF_BYTES,
64
+ SNIFF_BYTES,
65
65
  0,
66
66
  );
67
67
  if (bytesRead === 0) {
@@ -72,9 +72,9 @@ export async function loadFileKindAndText(
72
72
  const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
73
73
  if (
74
74
  detectedMimeType !== undefined &&
75
- !isTextLikeMimeType(detectedMimeType)
75
+ !isTextType(detectedMimeType)
76
76
  ) {
77
- if (IMAGE_MIME_TYPES.has(detectedMimeType)) {
77
+ if (IMG_TYPES.has(detectedMimeType)) {
78
78
  return { kind: "image", mimeType: detectedMimeType };
79
79
  }
80
80
  return {
@@ -82,7 +82,7 @@ export async function loadFileKindAndText(
82
82
  description: detectedMimeType,
83
83
  };
84
84
  }
85
- if (hasNullByte(sample)) {
85
+ if (hasNull(sample)) {
86
86
  return {
87
87
  kind: "binary",
88
88
  description: "null bytes detected",
@@ -92,7 +92,7 @@ export async function loadFileKindAndText(
92
92
  const decoder = new TextDecoder("utf-8");
93
93
  const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
94
94
  let hadUtf8DecodeErrors = false;
95
- const noteUtf8DecodeErrors = (chunk?: Uint8Array): void => {
95
+ const noteUtf8Err = (chunk?: Uint8Array): void => {
96
96
  if (hadUtf8DecodeErrors) return;
97
97
  try {
98
98
  fatalDecoder.decode(chunk, { stream: chunk !== undefined });
@@ -105,7 +105,7 @@ export async function loadFileKindAndText(
105
105
  }
106
106
  };
107
107
 
108
- noteUtf8DecodeErrors(sample);
108
+ noteUtf8Err(sample);
109
109
  const parts: string[] = [decoder.decode(sample, { stream: true })];
110
110
 
111
111
  let position = bytesRead;
@@ -113,7 +113,7 @@ export async function loadFileKindAndText(
113
113
  const { bytesRead: chunkBytesRead } = await fileHandle.read(
114
114
  buffer,
115
115
  0,
116
- FILE_TYPE_SNIFF_BYTES,
116
+ SNIFF_BYTES,
117
117
  position,
118
118
  );
119
119
  if (chunkBytesRead === 0) {
@@ -121,18 +121,18 @@ export async function loadFileKindAndText(
121
121
  }
122
122
 
123
123
  const chunk = buffer.subarray(0, chunkBytesRead);
124
- if (hasNullByte(chunk)) {
124
+ if (hasNull(chunk)) {
125
125
  return {
126
126
  kind: "binary",
127
127
  description: "null bytes detected",
128
128
  };
129
129
  }
130
- noteUtf8DecodeErrors(chunk);
130
+ noteUtf8Err(chunk);
131
131
  parts.push(decoder.decode(chunk, { stream: true }));
132
132
  position += chunkBytesRead;
133
133
  }
134
134
 
135
- noteUtf8DecodeErrors();
135
+ noteUtf8Err();
136
136
  parts.push(decoder.decode());
137
137
 
138
138
  return {
@@ -145,7 +145,7 @@ export async function loadFileKindAndText(
145
145
  }
146
146
  }
147
147
 
148
- export async function classifyFileKind(filePath: string): Promise<FileKind> {
148
+ export async function classifyFileKind(filePath: string): Promise<FKind> {
149
149
  const loaded = await loadFileKindAndText(filePath);
150
150
  switch (loaded.kind) {
151
151
  case "directory":
@@ -157,4 +157,4 @@ export async function classifyFileKind(filePath: string): Promise<FileKind> {
157
157
  case "text":
158
158
  return { kind: "text" };
159
159
  }
160
- }
160
+ }
@@ -0,0 +1,48 @@
1
+ import { constants } from "fs";
2
+ import { lineHashes } from "./hashline";
3
+ import { loadFileKindAndText, type LFile } from "./file-kind";
4
+ import { toCwd } from "./path-utils";
5
+ import { detectEnding, toLF, stripBOM } from "./replace-diff";
6
+ import { abortIf } from "./runtime";
7
+ import { assertText, valAccess } from "./validation";
8
+
9
+ export interface NormFile {
10
+ absolutePath: string;
11
+ normalized: string;
12
+ bom: string;
13
+ originalEnding: "\r\n" | "\n";
14
+ fileHashes: string[];
15
+ hadUtf8DecodeErrors: boolean;
16
+ }
17
+
18
+ export async function readNormFile(
19
+ path: string,
20
+ cwd: string,
21
+ signal: AbortSignal | undefined,
22
+ accessMode: number = constants.R_OK,
23
+ preloadedFile?: LFile,
24
+ ): Promise<NormFile> {
25
+ const absolutePath = toCwd(path, cwd);
26
+
27
+ abortIf(signal);
28
+ await valAccess(absolutePath, path, accessMode);
29
+
30
+ abortIf(signal);
31
+ const file = preloadedFile ?? (await loadFileKindAndText(absolutePath));
32
+ assertText(file, path);
33
+
34
+ abortIf(signal);
35
+ const { bom, text: rawContent } = stripBOM(file.text);
36
+ const originalEnding = detectEnding(rawContent);
37
+ const normalized = toLF(rawContent);
38
+ const fileHashes = lineHashes(normalized);
39
+
40
+ return {
41
+ absolutePath,
42
+ normalized,
43
+ bom,
44
+ originalEnding,
45
+ fileHashes,
46
+ hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
47
+ };
48
+ }
package/src/fs-write.ts CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  writeFile,
10
10
  } from "fs/promises";
11
11
  import { dirname, join, parse, resolve, sep } from "path";
12
+ import { errCode } from "./validation";
12
13
 
13
- export async function resolveMutationTargetPath(path: string): Promise<string> {
14
+ export async function resolveTarget(path: string): Promise<string> {
14
15
  const absolutePath = resolve(path);
15
16
  const { root } = parse(absolutePath);
16
17
  const parts = absolutePath
@@ -19,7 +20,7 @@ export async function resolveMutationTargetPath(path: string): Promise<string> {
19
20
  .filter((part) => part.length > 0);
20
21
  const visitedSymlinks = new Set<string>();
21
22
 
22
- async function resolveFromParts(
23
+ async function resParts(
23
24
  currentPath: string,
24
25
  remainingParts: string[],
25
26
  ): Promise<string> {
@@ -33,7 +34,7 @@ export async function resolveMutationTargetPath(path: string): Promise<string> {
33
34
  try {
34
35
  const candidateStats = await lstat(candidatePath);
35
36
  if (!candidateStats.isSymbolicLink()) {
36
- return resolveFromParts(candidatePath, tail);
37
+ return resParts(candidatePath, tail);
37
38
  }
38
39
 
39
40
  if (visitedSymlinks.has(candidatePath)) {
@@ -53,32 +54,32 @@ export async function resolveMutationTargetPath(path: string): Promise<string> {
53
54
  .slice(parse(linkTargetPath).root.length)
54
55
  .split(sep)
55
56
  .filter((part) => part.length > 0);
56
- return resolveFromParts(parse(linkTargetPath).root, [
57
+ return resParts(parse(linkTargetPath).root, [
57
58
  ...targetParts,
58
59
  ...tail,
59
60
  ]);
60
- } catch (error: unknown) {
61
- if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
62
- return join(candidatePath, ...tail);
63
- }
64
- throw error;
61
+ } catch (error: unknown) {
62
+ if (errCode(error) === "ENOENT") {
63
+ return join(candidatePath, ...tail);
65
64
  }
65
+ throw error;
66
+ }
66
67
  }
67
68
 
68
- return resolveFromParts(root, parts);
69
+ return resParts(root, parts);
69
70
  }
70
71
 
71
- export async function writeFileAtomically(
72
+ export async function writeAtomic(
72
73
  path: string,
73
74
  content: string,
74
75
  ): Promise<void> {
75
- const targetPath = await resolveMutationTargetPath(path);
76
+ const targetPath = await resolveTarget(path);
76
77
 
77
78
  let existingStats: Awaited<ReturnType<typeof stat>> | null = null;
78
79
  try {
79
80
  existingStats = await stat(targetPath);
80
81
  } catch (error: unknown) {
81
- if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
82
+ if (errCode(error) !== "ENOENT") {
82
83
  throw error;
83
84
  }
84
85
  }