pi-hashline-edit-pro 0.18.1 → 0.18.2
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 +3 -1
- package/index.ts +14 -3
- package/package.json +4 -1
- package/prompts/read-guidelines.md +2 -1
- package/prompts/read.md +1 -3
- package/prompts/replace-guidelines.md +2 -2
- package/prompts/undo-last-replace-guidelines.md +1 -0
- package/src/file-kind.ts +1 -1
- package/src/hashline/hash.ts +3 -0
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +3 -1
- package/src/read.ts +1 -6
- package/src/replace.ts +25 -26
package/README.md
CHANGED
|
@@ -142,7 +142,9 @@ The file is created automatically when any setting is toggled. Both fields are i
|
|
|
142
142
|
|
|
143
143
|
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
144
144
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
145
|
-
- **Strict patch content.** If `content_lines` contains `+HASH│`
|
|
145
|
+
- **Strict patch content.** If `content_lines` contains diff-preview rows — `+HASH│` addition prefixes, `-HASH│` or `- │` deletion rows (the padded format the diff preview emits), or `-N ` numbered deletion rows — the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
|
|
146
|
+
|
|
147
|
+
- **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
|
|
146
148
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
147
149
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
148
150
|
- **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { initHasher } from "./src/hashline";
|
|
3
3
|
import { regReplace, regReplaceFlat } from "./src/replace";
|
|
4
|
-
import { regReplaceUndo } from "./src/replace-undo";
|
|
4
|
+
import { regReplaceUndo, clearUndo } from "./src/replace-undo";
|
|
5
5
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
6
6
|
import { visLines } from "./src/utils";
|
|
7
7
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
} from "./src/config";
|
|
13
13
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
14
14
|
import { readNormFile } from "./src/file-reader";
|
|
15
|
-
|
|
15
|
+
import { toCwd } from "./src/paths";
|
|
16
|
+
import { resolveTarget } from "./src/fs-write";
|
|
16
17
|
function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
|
|
17
18
|
if (mode === "flat") {
|
|
18
19
|
regReplaceFlat(pi, autoRead);
|
|
@@ -73,8 +74,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
pi.on("tool_result", async (event, ctx) => {
|
|
76
|
-
if (!autoRead) return;
|
|
77
77
|
if (event.isError) return;
|
|
78
|
+
if (event.toolName === "write") {
|
|
79
|
+
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
80
|
+
if (typeof writtenPath === "string") {
|
|
81
|
+
try {
|
|
82
|
+
clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("Failed to clear undo after write:", error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!autoRead) return;
|
|
78
89
|
if (event.toolName !== "write" && event.toolName !== "replace") return;
|
|
79
90
|
|
|
80
91
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
41
41
|
"@earendil-works/pi-tui": "*"
|
|
42
42
|
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.13.0"
|
|
45
|
+
},
|
|
43
46
|
"scripts": {
|
|
44
47
|
"test": "vitest run",
|
|
45
48
|
"test:watch": "vitest",
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
1
|
+
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
2
|
+
- `read`: call again after any edit to that file — changed lines get new anchors.
|
package/prompts/read.md
CHANGED
|
@@ -1,3 +1 @@
|
|
|
1
|
-
Read a text file
|
|
2
|
-
|
|
3
|
-
Text → HASH│content lines. Images → visual attachments. Binary/directory → rejected. Empty → HASH│ (replace to insert). Pageable with offset/limit. BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
1
|
+
Read a text file; each line returned as HASH│content with a 3-char URL-safe base64 hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
- `replace`:
|
|
2
|
-
- `replace`:
|
|
1
|
+
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
|
|
2
|
+
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
|
package/src/file-kind.ts
CHANGED
|
@@ -81,7 +81,7 @@ export async function loadFileKindAndText(
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
84
|
+
const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
85
85
|
let hadUtf8DecodeErrors = false;
|
|
86
86
|
const parts: string[] = [];
|
|
87
87
|
|
package/src/hashline/hash.ts
CHANGED
|
@@ -40,6 +40,9 @@ export const HL_PREFIX_RE = new RegExp(
|
|
|
40
40
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
41
41
|
`^\\+\\s*${HASH_CLASS}│`,
|
|
42
42
|
);
|
|
43
|
+
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
44
|
+
`^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
|
|
45
|
+
);
|
|
43
46
|
export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
44
47
|
|
|
45
48
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
4
|
HL_PREFIX_PLUS_RE,
|
|
5
|
+
HL_PREFIX_MINUS_RE,
|
|
5
6
|
DIFF_MINUS_RE,
|
|
6
7
|
} from "./hash";
|
|
7
8
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
@@ -46,10 +47,11 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
46
47
|
if (!line.length) continue;
|
|
47
48
|
if (
|
|
48
49
|
HL_PREFIX_PLUS_RE.test(line) ||
|
|
50
|
+
HL_PREFIX_MINUS_RE.test(line) ||
|
|
49
51
|
DIFF_MINUS_RE.test(line)
|
|
50
52
|
) {
|
|
51
53
|
throw new Error(
|
|
52
|
-
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like
|
|
54
|
+
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
57
|
}
|
package/src/read.ts
CHANGED
|
@@ -2,8 +2,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import {
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
|
-
DEFAULT_MAX_BYTES,
|
|
6
|
-
DEFAULT_MAX_LINES,
|
|
7
5
|
truncateHead,
|
|
8
6
|
type TruncationResult,
|
|
9
7
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -18,10 +16,7 @@ import { visLines } from "./utils";
|
|
|
18
16
|
import { loadP, loadGuide } from "./prompts";
|
|
19
17
|
import { valAccess } from "./validation";
|
|
20
18
|
|
|
21
|
-
const R_DESC = loadP("../prompts/read.md"
|
|
22
|
-
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
|
|
23
|
-
DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
|
|
24
|
-
});
|
|
19
|
+
const R_DESC = loadP("../prompts/read.md");
|
|
25
20
|
|
|
26
21
|
const R_SNIPPET = loadP("../prompts/read-snippet.md");
|
|
27
22
|
const R_GUIDE = loadGuide("../prompts/read-guidelines.md");
|
package/src/replace.ts
CHANGED
|
@@ -68,7 +68,7 @@ const changeItemSchema = Type.Object(
|
|
|
68
68
|
|
|
69
69
|
export const editToolSchema = Type.Object(
|
|
70
70
|
{
|
|
71
|
-
changes: Type.Array(changeItemSchema, { description: "Array of edits
|
|
71
|
+
changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
|
|
72
72
|
path: Type.String({ description: "Path to edit" }),
|
|
73
73
|
},
|
|
74
74
|
{ additionalProperties: false },
|
|
@@ -117,6 +117,19 @@ interface PipelineResult {
|
|
|
117
117
|
|
|
118
118
|
const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
|
|
119
119
|
|
|
120
|
+
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
|
|
121
|
+
|
|
122
|
+
export function assertNoLegacyKeys(request: unknown): void {
|
|
123
|
+
if (!isRec(request)) return;
|
|
124
|
+
for (const legacyKey of LEGACY_KS) {
|
|
125
|
+
if (has(request, legacyKey)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
export function assertReq(
|
|
121
134
|
request: unknown,
|
|
122
135
|
flat?: boolean
|
|
@@ -125,13 +138,7 @@ export function assertReq(
|
|
|
125
138
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
|
|
129
|
-
if (has(request, legacyKey)) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
141
|
+
assertNoLegacyKeys(request);
|
|
135
142
|
|
|
136
143
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
137
144
|
|
|
@@ -269,6 +276,11 @@ export async function compPreview(
|
|
|
269
276
|
): Promise<RPreview> {
|
|
270
277
|
try {
|
|
271
278
|
const normalized = normReq(request);
|
|
279
|
+
if (flat && isRec(request) && Array.isArray(request.changes)) {
|
|
280
|
+
return {
|
|
281
|
+
error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send content_lines and hash_range_inclusive at the top level (one edit per call), or use bulk mode for multiple edits per call.`
|
|
282
|
+
};
|
|
283
|
+
}
|
|
272
284
|
assertReq(normalized, flat);
|
|
273
285
|
const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
|
|
274
286
|
normalized,
|
|
@@ -312,25 +324,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
312
324
|
|
|
313
325
|
const MODE_CFG = {
|
|
314
326
|
flat: {
|
|
315
|
-
desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
|
|
316
|
-
examples: [
|
|
317
|
-
"", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
|
|
318
|
-
].join("\n"),
|
|
319
|
-
rules: "",
|
|
320
|
-
requestStructure: [
|
|
321
|
-
"Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
|
|
322
|
-
].join("\n"),
|
|
323
327
|
prefix: "performing one edit per call",
|
|
324
328
|
},
|
|
325
329
|
bulk: {
|
|
326
|
-
desc: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.",
|
|
327
|
-
examples: [
|
|
328
|
-
"", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
|
|
329
|
-
].join("\n"),
|
|
330
|
-
rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
|
|
331
|
-
requestStructure: [
|
|
332
|
-
"Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
|
|
333
|
-
].join("\n"),
|
|
334
330
|
prefix: "batching all changes to a file in one call",
|
|
335
331
|
},
|
|
336
332
|
} as const;
|
|
@@ -355,6 +351,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
355
351
|
promptGuidelines: E_GUIDE,
|
|
356
352
|
prepareArguments: opts.flat
|
|
357
353
|
? (args: unknown) => {
|
|
354
|
+
assertNoLegacyKeys(args);
|
|
358
355
|
if (!isRec(args)) return args as any;
|
|
359
356
|
const record = { ...args };
|
|
360
357
|
normalizeFilePath(record);
|
|
@@ -363,8 +360,10 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
363
360
|
}
|
|
364
361
|
return record;
|
|
365
362
|
}
|
|
366
|
-
: (args: unknown) =>
|
|
367
|
-
|
|
363
|
+
: (args: unknown) => {
|
|
364
|
+
assertNoLegacyKeys(args);
|
|
365
|
+
return normReq(args) as ReqParams;
|
|
366
|
+
},
|
|
368
367
|
renderShell: "default",
|
|
369
368
|
renderCall(args, theme, context) {
|
|
370
369
|
const previewInput = getPreviewInput(args);
|