pi-hashline-edit-pro 0.11.3 → 0.11.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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": {
package/prompts/read.md CHANGED
@@ -12,6 +12,7 @@ File kinds:
12
12
  - Text files are returned as `HASH│content` lines.
13
13
  - Images (JPEG, PNG, GIF, WebP) are returned as visual attachments.
14
14
  - Binary files and directories are rejected with a descriptive error.
15
+ - Empty files are returned as a single empty-line hash (`HASH│`). Use replace on that hash to insert content.
15
16
 
16
17
  Non-UTF-8 bytes:
17
- - Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens.
18
+ - UTF-8 byte-order marks (BOM) are stripped. Editing a file with a BOM rewrites it without the BOM.
@@ -44,6 +44,22 @@ Examples:
44
44
  ] }
45
45
  ```
46
46
 
47
+ 4. Append after the last line (include the old last line so the new line is added after it):
48
+
49
+ ```json
50
+ { "path": "src/main.ts", "edits": [
51
+ { "hash_range_incl": ["ZPM", "ZPM"], "new_lines": ["old last line", "new line"] }
52
+ ] }
53
+ ```
54
+
55
+ 5. Seed content into an empty file (replace the single empty-line hash returned by read):
56
+
57
+ ```json
58
+ { "path": "src/main.ts", "edits": [
59
+ { "hash_range_incl": ["aB3", "aB3"], "new_lines": ["first line", "second line"] }
60
+ ] }
61
+ ```
62
+
47
63
  ⚠️ Common mistake: do not copy the `HASH│` prefix into `new_lines`.
48
64
 
49
65
  Wrong:
package/src/file-kind.ts CHANGED
@@ -3,158 +3,158 @@ import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
 
5
5
  const IMG_TYPES = new Set<string>([
6
- "image/jpeg",
7
- "image/png",
8
- "image/gif",
9
- "image/webp",
6
+ "image/jpeg",
7
+ "image/png",
8
+ "image/gif",
9
+ "image/webp",
10
10
  ]);
11
11
 
12
12
  const TEXT_TYPES = new Set<string>([
13
- "application/rtf",
14
- "application/xml",
15
- "application/x-ms-regedit",
13
+ "application/rtf",
14
+ "application/xml",
15
+ "application/x-ms-regedit",
16
16
  ]);
17
17
 
18
18
  function isTextType(mimeType: string): boolean {
19
- return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
19
+ return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
20
20
  }
21
21
 
22
22
  export type FKind =
23
- | { kind: "directory" }
24
- | { kind: "image"; mimeType: string }
25
- | { kind: "text" }
26
- | { kind: "binary"; description: string };
23
+ | { kind: "directory" }
24
+ | { kind: "image"; mimeType: string }
25
+ | { kind: "text" }
26
+ | { kind: "binary"; description: string };
27
27
 
28
28
  export type LFile =
29
- | { kind: "directory" }
30
- | { kind: "image"; mimeType: string }
31
- | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
32
- | { kind: "binary"; description: string };
29
+ | { kind: "directory" }
30
+ | { kind: "image"; mimeType: string }
31
+ | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
32
+ | { kind: "binary"; description: string };
33
33
 
34
34
  function hasNull(buffer: Uint8Array): boolean {
35
- return buffer.includes(0);
35
+ return buffer.includes(0);
36
36
  }
37
37
 
38
38
  export async function loadFileKindAndText(
39
- filePath: string,
39
+ filePath: string,
40
40
  ): Promise<LFile> {
41
- const pathStat = await fsStat(filePath);
42
- if (pathStat.isDirectory()) {
43
- return { kind: "directory" };
44
- }
45
- if (!pathStat.isFile()) {
46
- return {
47
- kind: "binary",
48
- description: "unsupported file type",
49
- };
50
- }
51
- if (pathStat.size > MAX_BYTES) {
52
- return {
53
- kind: "binary",
54
- description: `file exceeds ${MAX_BYTES} byte limit`
55
- };
56
- }
57
-
58
- const fileHandle = await fsOpen(filePath, "r");
59
- try {
60
- const buffer = Buffer.alloc(SNIFF_BYTES);
61
- const { bytesRead } = await fileHandle.read(
62
- buffer,
63
- 0,
64
- SNIFF_BYTES,
65
- 0,
66
- );
67
- if (bytesRead === 0) {
68
- return { kind: "text", text: "" };
69
- }
70
-
71
- const sample = buffer.subarray(0, bytesRead);
72
- const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
73
- if (
74
- detectedMimeType !== undefined &&
75
- !isTextType(detectedMimeType)
76
- ) {
77
- if (IMG_TYPES.has(detectedMimeType)) {
78
- return { kind: "image", mimeType: detectedMimeType };
79
- }
80
- return {
81
- kind: "binary",
82
- description: detectedMimeType,
83
- };
84
- }
85
- if (hasNull(sample)) {
86
- return {
87
- kind: "binary",
88
- description: "null bytes detected",
89
- };
90
- }
91
-
92
- const decoder = new TextDecoder("utf-8");
93
- const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
94
- let hadUtf8DecodeErrors = false;
95
- const noteUtf8Err = (chunk?: Uint8Array): void => {
96
- if (hadUtf8DecodeErrors) return;
97
- try {
98
- fatalDecoder.decode(chunk, { stream: chunk !== undefined });
99
- } catch (error: unknown) {
100
- if (error instanceof TypeError) {
101
- hadUtf8DecodeErrors = true;
102
- return;
103
- }
104
- throw error;
105
- }
106
- };
107
-
108
- noteUtf8Err(sample);
109
- const parts: string[] = [decoder.decode(sample, { stream: true })];
110
-
111
- let position = bytesRead;
112
- while (true) {
113
- const { bytesRead: chunkBytesRead } = await fileHandle.read(
114
- buffer,
115
- 0,
116
- SNIFF_BYTES,
117
- position,
118
- );
119
- if (chunkBytesRead === 0) {
120
- break;
121
- }
122
-
123
- const chunk = buffer.subarray(0, chunkBytesRead);
124
- if (hasNull(chunk)) {
125
- return {
126
- kind: "binary",
127
- description: "null bytes detected",
128
- };
129
- }
130
- noteUtf8Err(chunk);
131
- parts.push(decoder.decode(chunk, { stream: true }));
132
- position += chunkBytesRead;
133
- }
134
-
135
- noteUtf8Err();
136
- parts.push(decoder.decode());
137
-
138
- return {
139
- kind: "text",
140
- text: parts.join(""),
141
- ...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
142
- };
143
- } finally {
144
- await fileHandle.close();
145
- }
41
+ const pathStat = await fsStat(filePath);
42
+ if (pathStat.isDirectory()) {
43
+ return { kind: "directory" };
44
+ }
45
+ if (!pathStat.isFile()) {
46
+ return {
47
+ kind: "binary",
48
+ description: "unsupported file type",
49
+ };
50
+ }
51
+ if (pathStat.size > MAX_BYTES) {
52
+ return {
53
+ kind: "binary",
54
+ description: `file exceeds ${MAX_BYTES} byte limit`
55
+ };
56
+ }
57
+
58
+ const fileHandle = await fsOpen(filePath, "r");
59
+ try {
60
+ const buffer = Buffer.alloc(SNIFF_BYTES);
61
+ const { bytesRead } = await fileHandle.read(
62
+ buffer,
63
+ 0,
64
+ SNIFF_BYTES,
65
+ 0,
66
+ );
67
+ if (bytesRead === 0) {
68
+ return { kind: "text", text: "" };
69
+ }
70
+
71
+ const sample = buffer.subarray(0, bytesRead);
72
+ const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
73
+ if (
74
+ detectedMimeType !== undefined &&
75
+ !isTextType(detectedMimeType)
76
+ ) {
77
+ if (IMG_TYPES.has(detectedMimeType)) {
78
+ return { kind: "image", mimeType: detectedMimeType };
79
+ }
80
+ return {
81
+ kind: "binary",
82
+ description: detectedMimeType,
83
+ };
84
+ }
85
+ if (hasNull(sample)) {
86
+ return {
87
+ kind: "binary",
88
+ description: "null bytes detected",
89
+ };
90
+ }
91
+
92
+ const decoder = new TextDecoder("utf-8");
93
+ const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
94
+ let hadUtf8DecodeErrors = false;
95
+ const noteUtf8Err = (chunk?: Uint8Array): void => {
96
+ if (hadUtf8DecodeErrors) return;
97
+ try {
98
+ fatalDecoder.decode(chunk, { stream: chunk !== undefined });
99
+ } catch (error: unknown) {
100
+ if (error instanceof TypeError) {
101
+ hadUtf8DecodeErrors = true;
102
+ return;
103
+ }
104
+ throw error;
105
+ }
106
+ };
107
+
108
+ noteUtf8Err(sample);
109
+ const parts: string[] = [decoder.decode(sample, { stream: true })];
110
+
111
+ let position = bytesRead;
112
+ while (true) {
113
+ const { bytesRead: chunkBytesRead } = await fileHandle.read(
114
+ buffer,
115
+ 0,
116
+ SNIFF_BYTES,
117
+ position,
118
+ );
119
+ if (chunkBytesRead === 0) {
120
+ break;
121
+ }
122
+
123
+ const chunk = buffer.subarray(0, chunkBytesRead);
124
+ if (hasNull(chunk)) {
125
+ return {
126
+ kind: "binary",
127
+ description: "null bytes detected",
128
+ };
129
+ }
130
+ noteUtf8Err(chunk);
131
+ parts.push(decoder.decode(chunk, { stream: true }));
132
+ position += chunkBytesRead;
133
+ }
134
+
135
+ noteUtf8Err();
136
+ parts.push(decoder.decode());
137
+
138
+ return {
139
+ kind: "text",
140
+ text: parts.join(""),
141
+ ...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
142
+ };
143
+ } finally {
144
+ await fileHandle.close();
145
+ }
146
146
  }
147
147
 
148
148
  export async function classifyFileKind(filePath: string): Promise<FKind> {
149
- const loaded = await loadFileKindAndText(filePath);
150
- switch (loaded.kind) {
151
- case "directory":
152
- return loaded;
153
- case "image":
154
- return loaded;
155
- case "binary":
156
- return loaded;
157
- case "text":
158
- return { kind: "text" };
159
- }
149
+ const loaded = await loadFileKindAndText(filePath);
150
+ switch (loaded.kind) {
151
+ case "directory":
152
+ return loaded;
153
+ case "image":
154
+ return loaded;
155
+ case "binary":
156
+ return loaded;
157
+ case "text":
158
+ return { kind: "text" };
159
+ }
160
160
  }
package/src/fs-write.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  open,
6
6
  readlink,
7
7
  rename,
8
+ rm,
8
9
  stat,
9
10
  writeFile,
10
11
  } from "fs/promises";
@@ -102,5 +103,14 @@ export async function writeAtomic(
102
103
  await tempHandle.close();
103
104
  }
104
105
 
105
- await rename(tempPath, targetPath);
106
+ try {
107
+ await rename(tempPath, targetPath);
108
+ } catch (error: unknown) {
109
+ try {
110
+ await rm(tempPath, { force: true });
111
+ } catch {
112
+ // best-effort cleanup
113
+ }
114
+ throw error;
115
+ }
106
116
  }
@@ -216,7 +216,8 @@ export function applyEdits(
216
216
  edits: import("./resolve").HEdit[],
217
217
  signal?: AbortSignal,
218
218
  precomputedHashes?: string[],
219
- ): {
219
+ filePath?: string,
220
+ ): {
220
221
  content: string;
221
222
  firstChangedLine: number | undefined;
222
223
  lastChangedLine: number | undefined;
@@ -252,7 +253,7 @@ export function applyEdits(
252
253
  );
253
254
  if (mismatches.length) {
254
255
  throw new Error(
255
- fmtMismatch(mismatches, lineIndex.fileLines, fileHashes),
256
+ fmtMismatch(mismatches, lineIndex.fileLines, fileHashes, filePath),
256
257
  );
257
258
  }
258
259
 
@@ -62,6 +62,17 @@ function canon(line: string): string {
62
62
  return line.replace(/\r/g, "").trimEnd();
63
63
  }
64
64
 
65
+ /**
66
+ * Compute a unique 3-character hash for every line of `content`.
67
+ *
68
+ * Each line is hashed with xxHash32 over its canonical form (trailing
69
+ * whitespace and CR characters stripped). If the base hash collides with a
70
+ * hash already assigned to an earlier line, a retry counter (`:R{retry}`)
71
+ * is appended to the canonical content and the hash is recomputed until a
72
+ * unique anchor is found. This perfect-hashing step guarantees every line
73
+ * in a file receives a distinct anchor, even when multiple lines contain
74
+ * identical text (e.g. repeated `}` or `import` statements).
75
+ */
65
76
  export function lineHashes(content: string): string[] {
66
77
  const lines = content.split("\n");
67
78
  const hashes = new Array<string>(lines.length);
@@ -79,6 +79,7 @@ export function fmtMismatch(
79
79
  mismatches: HMismatch[],
80
80
  fileLines: string[],
81
81
  fileHashes: string[],
82
+ filePath?: string,
82
83
  ): string {
83
84
  assertAligned(fileLines, fileHashes, "fmtMismatch");
84
85
 
@@ -88,15 +89,15 @@ export function fmtMismatch(
88
89
 
89
90
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
90
91
  if (notFound.length > 0) {
91
- out.push(
92
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
93
- );
92
+ out.push(
93
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
94
+ );
94
95
  }
95
96
  if (ambiguous.length > 0) {
96
97
  if (out.length > 0) out.push("");
97
- out.push(
98
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
99
- );
98
+ out.push(
99
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
100
+ );
100
101
  for (const m of ambiguous) {
101
102
  const sample = (m.candidates ?? []).slice(0, 5);
102
103
  const more =
package/src/read.ts CHANGED
@@ -51,12 +51,14 @@ export function fmtReadPreview(
51
51
  const startLine = normPosInt(options.offset, "offset") ?? 1;
52
52
  if (totalLines === 0) {
53
53
  if (startLine === 1) {
54
+ const allHashes = precomputedHashes ?? lineHashes(text);
55
+ const emptyLineHash = allHashes[0] ?? "";
54
56
  return {
55
- text: "File is empty. Use edit to insert content.",
57
+ text: `${emptyLineHash}│\n[File is empty. Use replace to insert content.]`,
56
58
  };
57
59
  }
58
60
  return {
59
- text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use edit to insert content.`,
61
+ text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
60
62
  };
61
63
 
62
64
  }
@@ -12,6 +12,22 @@ function coerceEditsArray(edits: unknown): unknown {
12
12
  }
13
13
  }
14
14
 
15
+ function coerceNewLines(edits: unknown): unknown {
16
+ if (!Array.isArray(edits)) return edits;
17
+ return edits.map((edit: unknown) => {
18
+ if (!isRec(edit)) return edit;
19
+ if (typeof edit.new_lines !== "string") return edit;
20
+ try {
21
+ const parsed: unknown = JSON.parse(edit.new_lines);
22
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
23
+ return { ...edit, new_lines: parsed };
24
+ }
25
+ } catch {
26
+ // not valid JSON, leave as-is for downstream validation
27
+ }
28
+ return edit;
29
+ });
30
+ }
15
31
 
16
32
  export function normReq(input: unknown): unknown {
17
33
  if (!isRec(input)) {
@@ -29,8 +45,8 @@ export function normReq(input: unknown): unknown {
29
45
 
30
46
  if (hasEditsField) {
31
47
  record.edits = coerceEditsArray(record.edits);
48
+ record.edits = coerceNewLines(record.edits);
32
49
  }
33
50
 
34
-
35
51
  return record;
36
- }
52
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { normReq } from "./replace-normalize";
3
+ import type { HTEdit } from "./hashline";
3
4
  import type { ReqParams, ReplaceDetails } from "./replace";
4
5
  import { isRec } from "./utils";
5
6
 
@@ -31,12 +32,16 @@ export function getPreviewInput(
31
32
  return null;
32
33
  }
33
34
 
34
- const request: ReqParams = { path: normalized.path };
35
- if (Array.isArray(normalized.edits)) {
36
- request.edits = normalized.edits as any;
35
+ if (!Array.isArray(normalized.edits)) {
36
+ return null;
37
37
  }
38
38
 
39
- return request.edits !== undefined ? request : null;
39
+ const request: ReqParams = {
40
+ path: normalized.path,
41
+ edits: normalized.edits as HTEdit[],
42
+ };
43
+
44
+ return request;
40
45
  }
41
46
 
42
47
  export function colorLines(lines: string[], theme: FgT): string[] {
@@ -1,3 +1,4 @@
1
+ import type { ReplaceDetails } from "./replace";
1
2
  import { genDiff } from "./replace-diff";
2
3
  import {
3
4
  affRange,
@@ -10,7 +11,7 @@ import { ANCHOR_BUDGET } from "./constants";
10
11
  type TResult = {
11
12
  content: Array<{ type: "text"; text: string }>;
12
13
  isError?: boolean;
13
- details: any;
14
+ details: ReplaceDetails;
14
15
  };
15
16
 
16
17
  export type RMetrics = {
@@ -175,7 +176,7 @@ export function buildChanged(input: SuccessInput): TResult {
175
176
  : "Anchors omitted; use read for subsequent edits.";
176
177
  })()
177
178
  : resultLines.length === 0
178
- ? "File is empty. Use edit to insert content."
179
+ ? "File is empty. Use replace to insert content."
179
180
  : "";
180
181
  const text = [anchorsBlock, warningsBlock.trimStart()]
181
182
  .filter((section) => section.length > 0)
package/src/replace.ts CHANGED
@@ -66,16 +66,14 @@ const editItemSchema = Type.Object(
66
66
  export const editToolSchema = Type.Object(
67
67
  {
68
68
  path: Type.String({ description: "path" }),
69
- edits: Type.Optional(
70
- Type.Array(editItemSchema, { description: "edits over $path" }),
71
- ),
69
+ edits: Type.Array(editItemSchema, { description: "edits over $path" }),
72
70
  },
73
71
  { additionalProperties: false },
74
72
  );
75
73
 
76
74
  export type ReqParams = {
77
75
  path: string;
78
- edits?: HTEdit[];
76
+ edits: HTEdit[];
79
77
  };
80
78
 
81
79
  export type ReplaceDetails = {
@@ -113,8 +111,8 @@ export function assertReq(
113
111
  throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
114
112
  }
115
113
 
116
- if (has(request, "edits") && !Array.isArray(request.edits)) {
117
- throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided. Each edit is { hash_range_incl: ["<START>", "<END>"], new_lines: [...] }.');
114
+ if (!Array.isArray(request.edits)) {
115
+ throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array. Each edit is { hash_range_incl: ["<START>", "<END>"], new_lines: [...] }.');
118
116
  }
119
117
  }
120
118
 
@@ -157,6 +155,7 @@ async function execPipeline(
157
155
  resolved,
158
156
  signal,
159
157
  originalHashes,
158
+ path,
160
159
  );
161
160
 
162
161
  return {