pi-hashline-edit-pro 1.2.1 → 2.0.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 +15 -17
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/src/constants.ts +3 -3
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/parse.ts +10 -15
- package/src/hashline/resolve.ts +30 -50
- package/src/replace-normalize.ts +1 -20
- package/src/replace-render.ts +3 -7
- package/src/replace.ts +18 -37
package/README.md
CHANGED
|
@@ -42,8 +42,8 @@ kQm│}
|
|
|
42
42
|
```json
|
|
43
43
|
{
|
|
44
44
|
"path": "src/main.ts",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
45
|
+
"hash_bounds": ["szJ", "szJ"],
|
|
46
|
+
"new_content": " console.log('hi');"
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
@@ -77,31 +77,30 @@ Edge cases:
|
|
|
77
77
|
|
|
78
78
|
The built-in `edit` tool is disabled — `replace` is the only edit path; call it with the hash anchors from `read` output.
|
|
79
79
|
|
|
80
|
-
Exactly one edit per call, with `
|
|
80
|
+
Exactly one edit per call, with `hash_bounds` and `new_content` at the top level of the request:
|
|
81
81
|
|
|
82
82
|
```json
|
|
83
83
|
{
|
|
84
84
|
"path": "src/main.ts",
|
|
85
|
-
"
|
|
86
|
-
"
|
|
85
|
+
"hash_bounds": ["szJ", "kQm"],
|
|
86
|
+
"new_content": " console.log('hi');\n}"
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
| Field | Description |
|
|
91
91
|
| --- | --- |
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
92
|
+
| `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
|
|
93
|
+
| `new_content` | Replacement content as a single string with `\n` line separators; a trailing newline is the last line's ending, not an extra empty line. Use `""` to delete the range. |
|
|
94
94
|
|
|
95
95
|
Behavior:
|
|
96
96
|
|
|
97
97
|
- **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
|
|
98
|
-
- **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
|
|
99
98
|
- **Autocorrections** (all accompanied by a warning unless noted):
|
|
100
|
-
- A `HASH│` prefix accidentally left on a `
|
|
101
|
-
- Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `
|
|
99
|
+
- A `HASH│` prefix accidentally left on a `new_content` line is stripped.
|
|
100
|
+
- Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `new_content` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
|
|
102
101
|
- A reversed range (start hash after end hash) is swapped and applied.
|
|
103
102
|
- A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
|
|
104
|
-
- `file_path` is accepted as an alias for `path
|
|
103
|
+
- `file_path` is accepted as an alias for `path`.
|
|
105
104
|
- **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it — and reaches the model-visible text only while auto-read is on.
|
|
106
105
|
- **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
|
|
107
106
|
|
|
@@ -157,13 +156,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
157
156
|
|
|
158
157
|
| Code | Meaning |
|
|
159
158
|
| --- | --- |
|
|
160
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields
|
|
161
|
-
| `[E_BAD_REF]` | An anchor in `
|
|
159
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (e.g. `new_content` must be a string with `\n` line separators). |
|
|
160
|
+
| `[E_BAD_REF]` | An anchor in `hash_bounds` is not a bare 3-char hash. |
|
|
162
161
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
163
162
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
164
|
-
| `[E_INVALID_PATCH]` | A `
|
|
165
|
-
| `[E_BARE_HASH_PREFIX]` | A `
|
|
166
|
-
| `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
|
|
163
|
+
| `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
|
|
164
|
+
| `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
|
|
167
165
|
| `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
|
|
168
166
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
169
167
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
@@ -187,7 +185,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
187
185
|
- **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
|
|
188
186
|
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
|
|
189
187
|
- **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
|
|
190
|
-
- **One edit per call.** The request shape stays `{path,
|
|
188
|
+
- **One edit per call.** The request shape stays `{path, hash_bounds, new_content}` from schema through validation to application; there is no batching dialect.
|
|
191
189
|
|
|
192
190
|
## Troubleshooting
|
|
193
191
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
- `replace`:
|
|
2
|
-
- `replace`:
|
|
1
|
+
- `replace`: hash_bounds must use only anchors from the most recent read of the same file.
|
|
2
|
+
- `replace`: hash_bounds marks the exact lines that are REMOVED, and new_content is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in new_content is deleted from the file — including closing braces and other structural lines.
|
|
3
3
|
- `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
|
|
4
|
-
- `replace`: to replace a single line, repeat its hash in both positions of
|
|
5
|
-
- `replace`:
|
|
6
|
-
- `replace`:
|
|
4
|
+
- `replace`: to replace a single line, repeat its hash in both positions of hash_bounds: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
|
|
5
|
+
- `replace`: new_content is a single string with \n line separators — never a JSON array. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
6
|
+
- `replace`: a trailing newline in new_content is the last line's ending, not an extra empty line; use an explicit empty line (e.g. ending with \n\n) to add a blank 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.
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,6 @@ export const MAX_READ_LINE_BYTES = 200 * 1024;
|
|
|
5
5
|
|
|
6
6
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
7
7
|
export const HASH_STORE_VERSION = 5;
|
|
8
|
-
export const
|
|
9
|
-
`[E_BAD_SHAPE] "
|
|
10
|
-
+ ` Do not
|
|
8
|
+
export const NEW_CONTENT_NOT_STRING_MSG =
|
|
9
|
+
`[E_BAD_SHAPE] "new_content" must be a string with \\n line separators, not an array.`
|
|
10
|
+
+ ` Do not pass an array of lines — pass the replacement text as one string: "line1\\nline2". Use "" to delete a range.`;
|
package/src/hashline/apply.ts
CHANGED
|
@@ -65,8 +65,8 @@ function resToSpan(
|
|
|
65
65
|
): RESpan | NoopSpan {
|
|
66
66
|
const { fileLines, lineStarts } = lineIndex;
|
|
67
67
|
|
|
68
|
-
const startLine = edit.
|
|
69
|
-
const endLine = edit.
|
|
68
|
+
const startLine = edit.hash_bounds[0].line;
|
|
69
|
+
const endLine = edit.hash_bounds[1].line;
|
|
70
70
|
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
71
71
|
if (
|
|
72
72
|
originalLines.length === edit.content_lines.length &&
|
|
@@ -76,7 +76,7 @@ function resToSpan(
|
|
|
76
76
|
) {
|
|
77
77
|
return {
|
|
78
78
|
kind: "noop",
|
|
79
|
-
loc: edit.
|
|
79
|
+
loc: edit.hash_bounds[0].hash,
|
|
80
80
|
currentContent: originalLines.join("\n"),
|
|
81
81
|
};
|
|
82
82
|
}
|
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_STRING_MSG } from "../constants";
|
|
6
6
|
|
|
7
7
|
export type Anchor = { hash: string };
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ function diagRef(ref: string): string {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (trimmed.includes("│")) {
|
|
21
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}".
|
|
21
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_bounds must contain the 3-char hash only — remove everything from "│" onward.`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
|
@@ -39,18 +39,13 @@ function parseRef(ref: string): Anchor {
|
|
|
39
39
|
|
|
40
40
|
export const parseHashRef = parseRef;
|
|
41
41
|
|
|
42
|
-
export function parseText(edit: string
|
|
43
|
-
if (edit
|
|
44
|
-
throw new Error(
|
|
42
|
+
export function parseText(edit: string): string[] {
|
|
43
|
+
if (typeof edit !== "string") {
|
|
44
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
throw new Error(
|
|
52
|
-
`[E_BAD_SHAPE] "content_lines" entry at index ${lineBreakIndex} contains a \\r or \\n line break. Pass each line as its own array entry.`,
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
return edit;
|
|
46
|
+
const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
47
|
+
if (normalized === "") return [];
|
|
48
|
+
const lines = normalized.split("\n");
|
|
49
|
+
if (normalized.endsWith("\n")) lines.pop();
|
|
50
|
+
return lines;
|
|
56
51
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
|
|
2
2
|
import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
|
-
import {
|
|
4
|
+
import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
@@ -9,10 +9,10 @@ export type RAnchor = {
|
|
|
9
9
|
hashMatched: boolean;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export type HEdit = { content_lines: string[];
|
|
12
|
+
export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
|
|
13
13
|
export type RHEdit = {
|
|
14
14
|
content_lines: string[];
|
|
15
|
-
|
|
15
|
+
hash_bounds: [RAnchor, RAnchor];
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
interface HMismatch {
|
|
@@ -40,8 +40,8 @@ export interface NEdit {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export type HTEdit = {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
new_content: string;
|
|
44
|
+
hash_bounds: [string, string];
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
function resAnchorFromMap(
|
|
@@ -89,7 +89,7 @@ export function fmtMismatch(
|
|
|
89
89
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
90
90
|
if (notFound.length > 0) {
|
|
91
91
|
out.push(
|
|
92
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into
|
|
92
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_bounds of your next replace call.`
|
|
93
93
|
);
|
|
94
94
|
for (const m of notFound) {
|
|
95
95
|
const ctx = m.context;
|
|
@@ -107,7 +107,7 @@ export function fmtMismatch(
|
|
|
107
107
|
if (ambiguous.length > 0) {
|
|
108
108
|
if (out.length > 0) out.push("");
|
|
109
109
|
out.push(
|
|
110
|
-
`[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 of the start and end of the range you are replacing into
|
|
110
|
+
`[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 of the start and end of the range you are replacing into hash_bounds of your next replace call.`
|
|
111
111
|
);
|
|
112
112
|
for (const m of ambiguous) {
|
|
113
113
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
@@ -130,13 +130,7 @@ export function fmtMismatch(
|
|
|
130
130
|
return out.join("\n");
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const ITEM_KS = new Set(["
|
|
134
|
-
|
|
135
|
-
function isStrArr(value: unknown): value is string[] {
|
|
136
|
-
return (
|
|
137
|
-
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
138
|
-
);
|
|
139
|
-
}
|
|
133
|
+
const ITEM_KS = new Set(["new_content", "hash_bounds"]);
|
|
140
134
|
|
|
141
135
|
function isStrPair(value: unknown): value is [string, string] {
|
|
142
136
|
return (
|
|
@@ -147,36 +141,22 @@ function isStrPair(value: unknown): value is [string, string] {
|
|
|
147
141
|
}
|
|
148
142
|
|
|
149
143
|
function assertItem(edit: Record<string, unknown>): void {
|
|
150
|
-
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only {
|
|
144
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { new_content, hash_bounds }.");
|
|
151
145
|
|
|
152
|
-
if ("
|
|
146
|
+
if ("hash_bounds" in edit && !isStrPair(edit.hash_bounds)) {
|
|
153
147
|
throw new Error(
|
|
154
|
-
`[E_BAD_SHAPE] Field "
|
|
148
|
+
`[E_BAD_SHAPE] Field "hash_bounds" must be a pair of anchor strings [start, end].`,
|
|
155
149
|
);
|
|
156
150
|
}
|
|
157
|
-
if (!("
|
|
158
|
-
throw new Error(`[E_BAD_SHAPE] The edit requires a "
|
|
151
|
+
if (!("new_content" in edit)) {
|
|
152
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "new_content" field. Provide the replacement text (use "" to delete).`);
|
|
159
153
|
}
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
if (typeof val === "string") {
|
|
163
|
-
try {
|
|
164
|
-
const parsed = JSON.parse(val);
|
|
165
|
-
if (Array.isArray(parsed)) {
|
|
166
|
-
edit.content_lines = parsed;
|
|
167
|
-
} else {
|
|
168
|
-
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
169
|
-
}
|
|
170
|
-
} catch {
|
|
171
|
-
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
172
|
-
}
|
|
173
|
-
} else {
|
|
174
|
-
throw new Error(`[E_BAD_SHAPE] Field "content_lines" must be a string array.`);
|
|
175
|
-
}
|
|
154
|
+
if (typeof edit.new_content !== "string") {
|
|
155
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
176
156
|
}
|
|
177
|
-
if (!isStrPair(edit.
|
|
157
|
+
if (!isStrPair(edit.hash_bounds)) {
|
|
178
158
|
throw new Error(
|
|
179
|
-
`[E_BAD_SHAPE] The edit requires a "
|
|
159
|
+
`[E_BAD_SHAPE] The edit requires a "hash_bounds" pair of anchor strings [start, end].`,
|
|
180
160
|
);
|
|
181
161
|
}
|
|
182
162
|
}
|
|
@@ -184,10 +164,10 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
184
164
|
export function resEdit(edit: HTEdit): HEdit {
|
|
185
165
|
assertItem(edit as Record<string, unknown>);
|
|
186
166
|
|
|
187
|
-
const replaceLines = parseText(edit.
|
|
167
|
+
const replaceLines = parseText(edit.new_content);
|
|
188
168
|
return {
|
|
189
169
|
content_lines: replaceLines,
|
|
190
|
-
|
|
170
|
+
hash_bounds: [parseHashRef(edit.hash_bounds[0]), parseHashRef(edit.hash_bounds[1])],
|
|
191
171
|
};
|
|
192
172
|
}
|
|
193
173
|
|
|
@@ -217,7 +197,7 @@ export function stripBarePrefixes(
|
|
|
217
197
|
});
|
|
218
198
|
if (stripped.length === 0) return edit;
|
|
219
199
|
const locations = stripped
|
|
220
|
-
.map((s) => `
|
|
200
|
+
.map((s) => `new_content line ${s.lineIndex + 1}`)
|
|
221
201
|
.join(", ");
|
|
222
202
|
const matchedCount = stripped.filter((s) => s.matched).length;
|
|
223
203
|
const evidence =
|
|
@@ -253,7 +233,7 @@ export function stripDiffPrefixes(
|
|
|
253
233
|
return line;
|
|
254
234
|
});
|
|
255
235
|
if (stripped.length === 0) return edit;
|
|
256
|
-
const locations = stripped.map((i) => `
|
|
236
|
+
const locations = stripped.map((i) => `new_content line ${i + 1}`).join(", ");
|
|
257
237
|
warnings.push(
|
|
258
238
|
`[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
259
239
|
);
|
|
@@ -269,7 +249,7 @@ export function swapReversedRanges(
|
|
|
269
249
|
for (let i = 0; i < fileHashes.length; i++) {
|
|
270
250
|
lineByHash.set(fileHashes[i]!, i + 1);
|
|
271
251
|
}
|
|
272
|
-
const [startRef, endRef] = edit.
|
|
252
|
+
const [startRef, endRef] = edit.hash_bounds;
|
|
273
253
|
const startLine = lineByHash.get(startRef.hash);
|
|
274
254
|
const endLine = lineByHash.get(endRef.hash);
|
|
275
255
|
if (
|
|
@@ -280,9 +260,9 @@ export function swapReversedRanges(
|
|
|
280
260
|
return edit;
|
|
281
261
|
}
|
|
282
262
|
warnings.push(
|
|
283
|
-
`[E_BAD_OP] Autocorrected:
|
|
263
|
+
`[E_BAD_OP] Autocorrected: hash_bounds was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
284
264
|
);
|
|
285
|
-
return { ...edit,
|
|
265
|
+
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
286
266
|
}
|
|
287
267
|
|
|
288
268
|
function checkBoundaryDup(
|
|
@@ -334,21 +314,21 @@ export function valEdit(
|
|
|
334
314
|
};
|
|
335
315
|
|
|
336
316
|
abortIf(signal);
|
|
337
|
-
const startResolved = tryResolve(edit.
|
|
338
|
-
const endResolved = tryResolve(edit.
|
|
317
|
+
const startResolved = tryResolve(edit.hash_bounds[0]);
|
|
318
|
+
const endResolved = tryResolve(edit.hash_bounds[1]);
|
|
339
319
|
if (!startResolved || !endResolved) {
|
|
340
320
|
if (!startResolved && endResolved) {
|
|
341
|
-
const startMismatch = mismatches.findLast((m) => m.ref === edit.
|
|
321
|
+
const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[0]);
|
|
342
322
|
if (startMismatch && startMismatch.kind === "not_found") startMismatch.context = endResolved;
|
|
343
323
|
} else if (startResolved && !endResolved) {
|
|
344
|
-
const endMismatch = mismatches.findLast((m) => m.ref === edit.
|
|
324
|
+
const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[1]);
|
|
345
325
|
if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
|
|
346
326
|
}
|
|
347
327
|
return { resolved: undefined, mismatches, boundaryWarnings };
|
|
348
328
|
}
|
|
349
329
|
if (startResolved.line > endResolved.line) {
|
|
350
330
|
throw new Error(
|
|
351
|
-
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.
|
|
331
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_bounds[0].hash} and ${edit.hash_bounds[1].hash}).`,
|
|
352
332
|
);
|
|
353
333
|
}
|
|
354
334
|
const endLine = endResolved.line;
|
|
@@ -364,7 +344,7 @@ export function valEdit(
|
|
|
364
344
|
return {
|
|
365
345
|
resolved: {
|
|
366
346
|
content_lines: edit.content_lines,
|
|
367
|
-
|
|
347
|
+
hash_bounds: [startResolved, endResolved],
|
|
368
348
|
},
|
|
369
349
|
mismatches,
|
|
370
350
|
boundaryWarnings,
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
import { isRec
|
|
2
|
-
import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
|
|
3
|
-
|
|
4
|
-
export function tryParseContentLines(record: Record<string, unknown>, key: string): void {
|
|
5
|
-
const val = record[key];
|
|
6
|
-
if (typeof val !== "string") return;
|
|
7
|
-
try {
|
|
8
|
-
const parsed = JSON.parse(val);
|
|
9
|
-
if (Array.isArray(parsed)) {
|
|
10
|
-
record[key] = parsed;
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
|
-
} catch {
|
|
14
|
-
}
|
|
15
|
-
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
16
|
-
}
|
|
1
|
+
import { isRec } from "./utils";
|
|
17
2
|
|
|
18
3
|
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
19
4
|
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
@@ -31,9 +16,5 @@ export function normReq(input: unknown): unknown {
|
|
|
31
16
|
|
|
32
17
|
normalizeFilePath(record);
|
|
33
18
|
|
|
34
|
-
if (has(record, "content_lines") && typeof record.content_lines === "string") {
|
|
35
|
-
tryParseContentLines(record, "content_lines");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
19
|
return record;
|
|
39
20
|
}
|
package/src/replace-render.ts
CHANGED
|
@@ -32,18 +32,14 @@ export function getPreviewInput(
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
if (Array.isArray(normalized.
|
|
36
|
-
return { path: normalized.path, hash_range_inclusive: ["", ""] as [string, string], content_lines: [] };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (!Array.isArray(normalized.hash_range_inclusive) || !Array.isArray(normalized.content_lines)) {
|
|
35
|
+
if (!Array.isArray(normalized.hash_bounds) || typeof normalized.new_content !== "string") {
|
|
40
36
|
return null;
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
const request: ReqParams = {
|
|
44
40
|
path: normalized.path,
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
hash_bounds: normalized.hash_bounds as [string, string],
|
|
42
|
+
new_content: normalized.new_content,
|
|
47
43
|
};
|
|
48
44
|
return request;
|
|
49
45
|
}
|
package/src/replace.ts
CHANGED
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
type LineEnding,
|
|
13
13
|
} from "./replace-diff";
|
|
14
14
|
import { readNormFile } from "./file-reader";
|
|
15
|
-
import { normReq, normalizeFilePath
|
|
16
|
-
import { isRec,
|
|
15
|
+
import { normReq, normalizeFilePath } from "./replace-normalize";
|
|
16
|
+
import { isRec, rejectUnknownFields, abortIf } from "./utils";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
18
|
import { applyEdit,
|
|
19
19
|
lineHashes,
|
|
@@ -45,12 +45,12 @@ import { loadP, loadGuide } from "./prompts";
|
|
|
45
45
|
import { saveUndo } from "./replace-undo";
|
|
46
46
|
import { loadHashStore, type HashStore } from "./hash-store";
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const newContentSchema = Type.String({
|
|
49
49
|
description:
|
|
50
|
-
"Replacement content
|
|
50
|
+
"Replacement content as a single string with \\n line separators; a trailing newline is the last line's ending, not an extra empty line. Use \"\" to delete the range."
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
const
|
|
53
|
+
const hashBoundsSchema = Type.Array(
|
|
54
54
|
Type.String({ description: "A 3-char HASH from read output" }),
|
|
55
55
|
{
|
|
56
56
|
description: "Pair of 3-char hashes from read output marking the first and last line of the range to replace (inclusive).",
|
|
@@ -62,15 +62,15 @@ const hashRangeInclSchema = Type.Array(
|
|
|
62
62
|
export const editToolSchema = Type.Object(
|
|
63
63
|
{
|
|
64
64
|
path: Type.String({ description: "Path to edit" }),
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
hash_bounds: hashBoundsSchema,
|
|
66
|
+
new_content: newContentSchema,
|
|
67
67
|
},
|
|
68
68
|
{ additionalProperties: false },
|
|
69
69
|
);
|
|
70
70
|
export type ReqParams = {
|
|
71
71
|
path: string;
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
hash_bounds: [string, string];
|
|
73
|
+
new_content: string;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
export type ReplaceDetails = {
|
|
@@ -100,20 +100,7 @@ interface PipelineResult {
|
|
|
100
100
|
|
|
101
101
|
const PREVIEW_DEBOUNCE_MS = 150;
|
|
102
102
|
|
|
103
|
-
const ROOT_KS = new Set(["path", "
|
|
104
|
-
|
|
105
|
-
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
|
|
106
|
-
|
|
107
|
-
export function assertNoLegacyKeys(request: unknown): void {
|
|
108
|
-
if (!isRec(request)) return;
|
|
109
|
-
for (const legacyKey of LEGACY_KS) {
|
|
110
|
-
if (has(request, legacyKey)) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
103
|
+
const ROOT_KS = new Set(["path", "new_content", "hash_bounds"]);
|
|
117
104
|
|
|
118
105
|
export function assertReq(
|
|
119
106
|
request: unknown,
|
|
@@ -122,17 +109,15 @@ export function assertReq(
|
|
|
122
109
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
123
110
|
}
|
|
124
111
|
|
|
125
|
-
assertNoLegacyKeys(request);
|
|
126
|
-
|
|
127
112
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
128
113
|
|
|
129
114
|
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
130
115
|
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
131
116
|
}
|
|
132
117
|
|
|
133
|
-
if (!Array.isArray(request.
|
|
118
|
+
if (!Array.isArray(request.hash_bounds) || typeof request.new_content !== "string") {
|
|
134
119
|
throw new Error(
|
|
135
|
-
'[E_BAD_SHAPE] Edit request requires both "
|
|
120
|
+
'[E_BAD_SHAPE] Edit request requires both "hash_bounds" and "new_content" at the top level.',
|
|
136
121
|
);
|
|
137
122
|
}
|
|
138
123
|
}
|
|
@@ -149,8 +134,8 @@ function collectRemovedHashes(
|
|
|
149
134
|
originalHashes: string[],
|
|
150
135
|
): Set<string> {
|
|
151
136
|
const removedHashes = new Set<string>();
|
|
152
|
-
const startHash = edit.
|
|
153
|
-
const endHash = edit.
|
|
137
|
+
const startHash = edit.hash_bounds[0].hash;
|
|
138
|
+
const endHash = edit.hash_bounds[1].hash;
|
|
154
139
|
const startLine = originalHashes.indexOf(startHash);
|
|
155
140
|
const endLine = originalHashes.indexOf(endHash);
|
|
156
141
|
if (startLine >= 0 && endLine >= 0) {
|
|
@@ -171,8 +156,8 @@ function countLineChanges(
|
|
|
171
156
|
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
172
157
|
if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
|
|
173
158
|
let totalRemovedLines = 0;
|
|
174
|
-
const startLine = originalHashes.indexOf(edit.
|
|
175
|
-
const endLine = originalHashes.indexOf(edit.
|
|
159
|
+
const startLine = originalHashes.indexOf(edit.hash_bounds[0].hash);
|
|
160
|
+
const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
|
|
176
161
|
if (startLine >= 0 && endLine >= 0) {
|
|
177
162
|
totalRemovedLines = Math.abs(endLine - startLine) + 1;
|
|
178
163
|
}
|
|
@@ -191,8 +176,8 @@ export async function execPipeline(
|
|
|
191
176
|
const path = params.path;
|
|
192
177
|
|
|
193
178
|
const edit = resEdit({
|
|
194
|
-
|
|
195
|
-
|
|
179
|
+
hash_bounds: params.hash_bounds,
|
|
180
|
+
new_content: params.new_content,
|
|
196
181
|
});
|
|
197
182
|
|
|
198
183
|
const hashStore = options?.store ?? await loadHashStore();
|
|
@@ -307,13 +292,9 @@ export function buildToolDef(): ToolDef {
|
|
|
307
292
|
promptSnippet: E_SNIPPET,
|
|
308
293
|
promptGuidelines: E_GUIDE,
|
|
309
294
|
prepareArguments: (args: unknown) => {
|
|
310
|
-
assertNoLegacyKeys(args);
|
|
311
295
|
if (!isRec(args)) return args as any;
|
|
312
296
|
const record = { ...args };
|
|
313
297
|
normalizeFilePath(record);
|
|
314
|
-
if (has(record, "content_lines") && typeof record.content_lines === "string") {
|
|
315
|
-
tryParseContentLines(record, "content_lines");
|
|
316
|
-
}
|
|
317
298
|
return record;
|
|
318
299
|
},
|
|
319
300
|
renderShell: "default",
|