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 +5 -3
- package/prompts/replace.md +27 -1
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +7 -3
- package/src/hashline/resolve.ts +7 -3
- package/src/read.ts +2 -3
- package/src/replace.ts +14 -15
- package/src/validation.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.
|
|
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
|
}
|
package/prompts/replace.md
CHANGED
|
@@ -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]` —
|
|
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/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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 —
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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):
|
|
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
|
}
|