pi-hashline-edit-pro 0.9.7 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.9.7",
3
+ "version": "0.10.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.
@@ -12,6 +12,7 @@ export {
12
12
  HASHLINE_BARE_PREFIX_RE,
13
13
  computeLineHashes,
14
14
  computeLineHash,
15
+ ensureHasherReady,
15
16
  } from "./hash";
16
17
 
17
18
  export {
@@ -15,14 +15,18 @@ function diagnoseHashRef(ref: string): string {
15
15
  const trimmed = ref.trim();
16
16
 
17
17
  if (!trimmed.length) {
18
- return `[E_BAD_REF] Invalid anchor. Expected a 3-character base64 anchor (e.g. \"aB3\").`;
18
+ return `[E_BAD_REF] Invalid anchor. Expected a 3-character base64 anchor (e.g. "aB3").`;
19
19
  }
20
20
 
21
21
  if (/^\d+/.test(trimmed)) {
22
- return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. \"aB3\") — no line numbers or trailing content.`;
22
+ return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3") — no line numbers or trailing content.`;
23
23
  }
24
24
 
25
- return `[E_BAD_REF] Invalid anchor \"${trimmed}\". Expected a 3-character base64 anchor (e.g. \"aB3\").`;
25
+ if (trimmed.includes("")) {
26
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". old_range must contain the 3-character hash only — remove everything from "│" onward (including the separator).`;
27
+ }
28
+
29
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-character base64 anchor (e.g. "aB3").`;
26
30
  }
27
31
 
28
32
  function parseAnchorRef(ref: string): Anchor {
@@ -209,19 +209,23 @@ export function assertNoBareHashPrefixLines(
209
209
  }
210
210
  if (suspects.length === 0) return [];
211
211
 
212
+ const locations = suspects
213
+ .map((s) => `edit ${s.editIndex}, new_lines[${s.lineIndex}]`)
214
+ .join("; ");
215
+
212
216
  const fileHashSet = new Set(fileHashes);
213
217
  const matched = suspects.filter((s) => fileHashSet.has(s.hash));
214
218
  const matchedCount = matched.length;
215
- const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
216
219
 
220
+ const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
217
221
 
218
222
  const linesHint =
219
223
  matchedCount === 0
220
224
  ? `None match file line hashes.`
221
- : `${matchedCount} match file line hashes — likely a copied hash.`;
225
+ : `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
222
226
 
223
227
  throw new Error(
224
- `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (e.g. ${JSON.stringify(exampleLine)}). ${linesHint} Use literal file content in \"lines\" never paste HASH│content from read output.`
228
+ `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected new_lines entry; keep only the literal line content that appears after "" in read output. Remember: old_range uses hash anchors, new_lines uses file content only.`
225
229
  );
226
230
  }
227
231
 
package/src/read.ts CHANGED
@@ -16,7 +16,7 @@ import { throwIfAborted } from "./runtime";
16
16
  import { getFileSnapshot } from "./snapshot";
17
17
  import { getVisibleLines } from "./utils";
18
18
  import { loadPrompt, loadPromptGuidelines } from "./prompts";
19
- import { validateFileAccess, validateFileKind, isTextFile } from "./validation";
19
+ import { validateFileAccess, assertTextFile } from "./validation";
20
20
 
21
21
  const READ_DESC = loadPrompt("../prompts/read.md", {
22
22
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
@@ -140,8 +140,6 @@ export function registerReadTool(pi: ExtensionAPI): void {
140
140
 
141
141
  throwIfAborted(signal);
142
142
  const file = await loadFileKindAndText(absolutePath);
143
- validateFileKind(file, rawPath);
144
-
145
143
  if (file.kind === "image") {
146
144
  const builtinRead = createReadTool(ctx.cwd);
147
145
  const executeBuiltinRead = builtinRead.execute as unknown as (
@@ -153,6 +151,7 @@ export function registerReadTool(pi: ExtensionAPI): void {
153
151
  ) => ReturnType<typeof builtinRead.execute>;
154
152
  return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
155
153
  }
154
+ assertTextFile(file, rawPath);
156
155
 
157
156
  throwIfAborted(signal);
158
157
  const normalized = normalizeToLF(stripBom(file.text).text);
package/src/replace.ts CHANGED
@@ -44,18 +44,18 @@ import {
44
44
  type ReplaceRenderState,
45
45
  } from "./replace-render";
46
46
  import { loadPrompt, loadPromptGuidelines } from "./prompts";
47
- import { validateFileAccess, validateFileKind, isTextFile } from "./validation";
47
+ import { validateFileAccess, assertTextFile } from "./validation";
48
48
 
49
49
 
50
50
  const hashlineEditNewLinesSchema = Type.Array(Type.String(), {
51
51
  description:
52
- "replacement content, one array entry per line, no HASH| prefix",
52
+ "literal replacement file content, one string per line. Must not include the HASH prefix from read output.",
53
53
  });
54
54
 
55
55
  const hasheditOldRangeSchema = Type.Array(
56
56
  Type.String({ description: "anchor (3-char HASH)" }),
57
57
  {
58
- description: "inclusive line range to replace [start, end]",
58
+ description: "inclusive line range to replace [start_hash, end_hash]. Each element must be the 3-character hash anchor only; do not include the │ separator or line content.",
59
59
  minItems: 2,
60
60
  maxItems: 2,
61
61
  },
@@ -169,7 +169,7 @@ async function executeEditPipeline(
169
169
 
170
170
  throwIfAborted(signal);
171
171
  const file = await loadFileKindAndText(absolutePath);
172
- validateFileKind(file, path);
172
+ assertTextFile(file, path);
173
173
 
174
174
  throwIfAborted(signal);
175
175
  const { bom, text: rawContent } = stripBom(file.text);
@@ -390,17 +390,16 @@ const editToolDefinition: EditToolDefinition = {
390
390
 
391
391
  if (originalNormalized === result) {
392
392
  const noopSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
393
- return buildNoopResponse({
394
- path,
395
- noopEdits,
396
- originalNormalized,
397
- snapshotId: noopSnapshotId,
398
- editMeta: {
399
- editsAttempted,
400
- noopEditsCount: noopEdits?.length ?? 0,
401
- },
402
- warnings,
403
- });
393
+ return buildNoopResponse({
394
+ path,
395
+ noopEdits,
396
+ snapshotId: noopSnapshotId,
397
+ editMeta: {
398
+ editsAttempted,
399
+ noopEditsCount: noopEdits?.length ?? 0,
400
+ },
401
+ warnings,
402
+ });
404
403
  }
405
404
 
406
405
  if (hadUtf8DecodeErrors) {
package/src/validation.ts CHANGED
@@ -22,7 +22,7 @@ export async function validateFileAccess(
22
22
  }
23
23
  }
24
24
 
25
- export function validateFileKind(file: LoadedFile, path: string): void {
25
+ export function validateFileKind(file: LoadedFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
26
26
  if (file.kind === "directory") {
27
27
  throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
28
28
  }
@@ -34,6 +34,10 @@ export function validateFileKind(file: LoadedFile, path: string): void {
34
34
  }
35
35
  }
36
36
 
37
+ export function assertTextFile(file: LoadedFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
38
+ validateFileKind(file, path);
39
+ }
40
+
37
41
  export function isTextFile(file: LoadedFile): file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
38
42
  return file.kind === "text";
39
43
  }