pi-hashline-edit-pro 2.5.3 → 2.6.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/README.md +8 -8
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +2 -2
- package/src/constants.ts +3 -3
- package/src/hashline/parse.ts +17 -8
- package/src/hashline/resolve.ts +11 -11
- package/src/replace-render.ts +3 -2
- package/src/replace.ts +17 -10
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ kQm│}
|
|
|
32
32
|
"path": "src/main.ts",
|
|
33
33
|
"remove_from": "szJ",
|
|
34
34
|
"remove_to": "szJ",
|
|
35
|
-
"
|
|
35
|
+
"replacement_lines": [" console.log('hi');"]
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -76,14 +76,14 @@ Edge cases:
|
|
|
76
76
|
|
|
77
77
|
The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
|
|
78
78
|
|
|
79
|
-
One edit per call, with `remove_from`, `remove_to`, and `
|
|
79
|
+
One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at the top level:
|
|
80
80
|
|
|
81
81
|
```json
|
|
82
82
|
{
|
|
83
83
|
"path": "src/main.ts",
|
|
84
84
|
"remove_from": "szJ",
|
|
85
85
|
"remove_to": "kQm",
|
|
86
|
-
"
|
|
86
|
+
"replacement_lines": [" console.log('hi');", "}"]
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
@@ -91,12 +91,12 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_text` at th
|
|
|
91
91
|
| --- | --- |
|
|
92
92
|
| `remove_from` | 3-char hash from `read` output marking the FIRST line to remove (inclusive). |
|
|
93
93
|
| `remove_to` | 3-char hash from `read` output marking the LAST line to remove (inclusive). |
|
|
94
|
-
| `
|
|
94
|
+
| `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element — each element is exactly one line. |
|
|
95
95
|
|
|
96
96
|
Notes:
|
|
97
97
|
|
|
98
98
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
99
|
-
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `
|
|
99
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
|
|
100
100
|
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
101
101
|
- Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
|
|
102
102
|
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
@@ -157,12 +157,12 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
157
157
|
|
|
158
158
|
| Code | Meaning |
|
|
159
159
|
| --- | --- |
|
|
160
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `
|
|
160
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
|
|
161
161
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char hash. |
|
|
162
162
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
163
163
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
164
|
-
| `[E_INVALID_PATCH]` | A `
|
|
165
|
-
| `[E_BARE_HASH_PREFIX]` | A `
|
|
164
|
+
| `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
|
|
165
|
+
| `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
|
|
166
166
|
| `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
|
|
167
167
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
168
168
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
- `replace`: remove_from and remove_to take ONLY the bare 3-char hash — read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `HASH│content` row into these fields.
|
|
2
|
-
- `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and
|
|
2
|
+
- `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_lines is deleted from the file.
|
|
3
3
|
- `replace`: keep the range as tight as the change — anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
|
|
4
4
|
- `replace`: to replace a single line, set both remove_from and remove_to to the same hash: remove_from: "<HASH>", remove_to: "<HASH>".
|
|
5
5
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
6
|
-
- `replace`:
|
|
6
|
+
- `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element — each element is exactly one line.
|
|
7
7
|
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
|
|
8
8
|
- `replace`: `[E_RANGE_STALE]` means a line inside the replaced range changed on disk after it was last shown (or was never shown). Nothing was modified; the error lists the current range as `HASH│content` rows, so retry with those anchors and no `read`.
|
|
9
9
|
- `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
|
package/src/constants.ts
CHANGED
|
@@ -6,6 +6,6 @@ export const MAX_RANGE_STALE_LINES = 100;
|
|
|
6
6
|
|
|
7
7
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
8
8
|
export const HASH_STORE_VERSION = 5;
|
|
9
|
-
export const
|
|
10
|
-
`[E_BAD_SHAPE] "
|
|
11
|
-
+ ` Do not pass
|
|
9
|
+
export const NEW_CONTENT_NOT_ARRAY_MSG =
|
|
10
|
+
`[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one element per line, not a single string.`
|
|
11
|
+
+ ` Do not pass one string with \\n separators — pass an array of lines: ["line1", "line2"]. Use [] to delete a range.`;
|
package/src/hashline/parse.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
4
|
} from "./hash";
|
|
5
|
-
import {
|
|
5
|
+
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
6
6
|
|
|
7
7
|
export type Anchor = { hash: string };
|
|
8
8
|
|
|
@@ -39,12 +39,21 @@ function parseRef(ref: string): Anchor {
|
|
|
39
39
|
|
|
40
40
|
export const parseHashRef = parseRef;
|
|
41
41
|
|
|
42
|
-
export function parseText(edit: string): string[] {
|
|
43
|
-
if (
|
|
44
|
-
throw new Error(
|
|
42
|
+
export function parseText(edit: string[], warnings?: string[]): string[] {
|
|
43
|
+
if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
|
|
44
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
45
45
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
let split = false;
|
|
48
|
+
for (const line of edit) {
|
|
49
|
+
const normalized = line.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
50
|
+
if (normalized !== line) split = true;
|
|
51
|
+
out.push(...normalized.split("\n"));
|
|
52
|
+
}
|
|
53
|
+
if (split) {
|
|
54
|
+
warnings?.push(
|
|
55
|
+
"[E_BAD_SHAPE] Autocorrected: split replacement_lines element(s) containing embedded newlines into separate lines.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
50
59
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
2
|
import { HASH_CLASS, HASH_SEP, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
|
-
import {
|
|
4
|
+
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
@@ -39,7 +39,7 @@ export interface NEdit {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export type HTEdit = {
|
|
42
|
-
|
|
42
|
+
replacement_lines: string[];
|
|
43
43
|
remove_from: string;
|
|
44
44
|
remove_to: string;
|
|
45
45
|
};
|
|
@@ -134,10 +134,10 @@ export function fmtMismatchWithHashes(
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
const ITEM_KS = new Set(["
|
|
137
|
+
const ITEM_KS = new Set(["replacement_lines", "remove_from", "remove_to"]);
|
|
138
138
|
|
|
139
139
|
function assertItem(edit: Record<string, unknown>): void {
|
|
140
|
-
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only {
|
|
140
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
|
|
141
141
|
|
|
142
142
|
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
143
143
|
throw new Error(
|
|
@@ -149,11 +149,11 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
149
149
|
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char hash).`,
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
|
-
if (!("
|
|
153
|
-
throw new Error(`[E_BAD_SHAPE] The edit requires a "
|
|
152
|
+
if (!("replacement_lines" in edit)) {
|
|
153
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" field. Provide the replacement lines as an array of strings (use [] to delete).`);
|
|
154
154
|
}
|
|
155
|
-
if (
|
|
156
|
-
throw new Error(
|
|
155
|
+
if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
|
|
156
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
157
157
|
}
|
|
158
158
|
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
159
159
|
throw new Error(
|
|
@@ -167,7 +167,7 @@ const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
|
|
|
167
167
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
168
168
|
assertItem(edit as Record<string, unknown>);
|
|
169
169
|
|
|
170
|
-
const replaceLines = parseText(edit.
|
|
170
|
+
const replaceLines = parseText(edit.replacement_lines, warnings);
|
|
171
171
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
172
172
|
const trimmed = ref.trim();
|
|
173
173
|
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
@@ -217,7 +217,7 @@ export function stripBarePrefixes(
|
|
|
217
217
|
});
|
|
218
218
|
if (stripped.length === 0) return edit;
|
|
219
219
|
const locations = stripped
|
|
220
|
-
.map((s) => `
|
|
220
|
+
.map((s) => `replacement_lines line ${s.lineIndex + 1}`)
|
|
221
221
|
.join(", ");
|
|
222
222
|
const matchedCount = stripped.filter((s) => s.matched).length;
|
|
223
223
|
const evidence =
|
|
@@ -253,7 +253,7 @@ export function stripDiffPrefixes(
|
|
|
253
253
|
return line;
|
|
254
254
|
});
|
|
255
255
|
if (stripped.length === 0) return edit;
|
|
256
|
-
const locations = stripped.map((i) => `
|
|
256
|
+
const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
|
|
257
257
|
warnings.push(
|
|
258
258
|
`[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
259
259
|
);
|
package/src/replace-render.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function getPreviewInput(
|
|
|
35
35
|
if (
|
|
36
36
|
typeof normalized.remove_from !== "string" ||
|
|
37
37
|
typeof normalized.remove_to !== "string" ||
|
|
38
|
-
|
|
38
|
+
!Array.isArray(normalized.replacement_lines) ||
|
|
39
|
+
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
39
40
|
) {
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
@@ -44,7 +45,7 @@ export function getPreviewInput(
|
|
|
44
45
|
path: normalized.path,
|
|
45
46
|
remove_from: normalized.remove_from,
|
|
46
47
|
remove_to: normalized.remove_to,
|
|
47
|
-
|
|
48
|
+
replacement_lines: normalized.replacement_lines,
|
|
48
49
|
};
|
|
49
50
|
return request;
|
|
50
51
|
}
|
package/src/replace.ts
CHANGED
|
@@ -48,10 +48,16 @@ import { saveUndo } from "./replace-undo";
|
|
|
48
48
|
import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
|
|
49
49
|
import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
|
|
50
50
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
const replacementLinesSchema = Type.Array(
|
|
52
|
+
Type.String({
|
|
53
|
+
description:
|
|
54
|
+
"One replacement line. Each element is exactly one line; do not embed \\n inside an element — use separate elements.",
|
|
55
|
+
}),
|
|
56
|
+
{
|
|
57
|
+
description:
|
|
58
|
+
"Replacement lines as an array of strings, one element per line. Use [] to delete the range."
|
|
59
|
+
}
|
|
60
|
+
);
|
|
55
61
|
|
|
56
62
|
const removeFromSchema = Type.String({
|
|
57
63
|
description: "Bare 3-char HASH only (e.g. \"aB3\") — copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
|
|
@@ -66,7 +72,7 @@ export const editToolSchema = Type.Object(
|
|
|
66
72
|
path: Type.Optional(Type.String({ description: "Path to edit. Required — always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
|
|
67
73
|
remove_from: removeFromSchema,
|
|
68
74
|
remove_to: removeToSchema,
|
|
69
|
-
|
|
75
|
+
replacement_lines: replacementLinesSchema,
|
|
70
76
|
},
|
|
71
77
|
{ additionalProperties: false },
|
|
72
78
|
);
|
|
@@ -74,7 +80,7 @@ export type ReqParams = {
|
|
|
74
80
|
path: string;
|
|
75
81
|
remove_from: string;
|
|
76
82
|
remove_to: string;
|
|
77
|
-
|
|
83
|
+
replacement_lines: string[];
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
export type ReplaceDetails = {
|
|
@@ -104,7 +110,7 @@ interface PipelineResult {
|
|
|
104
110
|
|
|
105
111
|
const PREVIEW_DEBOUNCE_MS = 150;
|
|
106
112
|
|
|
107
|
-
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "
|
|
113
|
+
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
108
114
|
|
|
109
115
|
export function assertReq(
|
|
110
116
|
request: unknown,
|
|
@@ -122,10 +128,11 @@ export function assertReq(
|
|
|
122
128
|
if (
|
|
123
129
|
typeof request.remove_from !== "string" ||
|
|
124
130
|
typeof request.remove_to !== "string" ||
|
|
125
|
-
|
|
131
|
+
!Array.isArray(request.replacement_lines) ||
|
|
132
|
+
request.replacement_lines.some((line) => typeof line !== "string")
|
|
126
133
|
) {
|
|
127
134
|
throw new Error(
|
|
128
|
-
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "
|
|
135
|
+
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" at the top level. replacement_lines must be an array of strings, one element per line (use [] to delete).',
|
|
129
136
|
);
|
|
130
137
|
}
|
|
131
138
|
}
|
|
@@ -224,7 +231,7 @@ export async function execPipeline(
|
|
|
224
231
|
{
|
|
225
232
|
remove_from: params.remove_from,
|
|
226
233
|
remove_to: params.remove_to,
|
|
227
|
-
|
|
234
|
+
replacement_lines: params.replacement_lines,
|
|
228
235
|
},
|
|
229
236
|
editWarnings,
|
|
230
237
|
);
|