pi-hashline-edit-pro 0.15.5 → 0.16.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/index.ts +2 -1
- package/package.json +1 -1
- package/prompts/replace-bulk.md +3 -3
- package/prompts/replace-flat.md +4 -1
- package/prompts/replace.md +4 -1
- package/src/file-reader.ts +41 -39
- package/src/replace-normalize.ts +61 -15
- package/src/replace-response.ts +5 -2
- package/src/replace-undo.ts +122 -0
- package/src/replace.ts +14 -1
- package/src/undo-store.ts +35 -0
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join, isAbsolute } from "path";
|
|
|
4
4
|
import { initHasher } from "./src/hashline";
|
|
5
5
|
import { regReplace } from "./src/replace";
|
|
6
6
|
import { regReplaceFlat } from "./src/replace-flat";
|
|
7
|
+
import { regReplaceUndo } from "./src/replace-undo";
|
|
7
8
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
8
9
|
import { toLF, stripBOM } from "./src/replace-diff";
|
|
9
10
|
import { visLines } from "./src/utils";
|
|
@@ -22,7 +23,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
22
23
|
// Register the bulk-mode replace tool by default. The session_start handler
|
|
23
24
|
// will re-register with the correct mode from the persisted config.
|
|
24
25
|
regReplace(pi);
|
|
25
|
-
|
|
26
|
+
regReplaceUndo(pi);
|
|
26
27
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
27
28
|
// Initial auto-read from env var; session_start overrides with persisted value
|
|
28
29
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
package/package.json
CHANGED
package/prompts/replace-bulk.md
CHANGED
|
@@ -94,9 +94,7 @@ Rules:
|
|
|
94
94
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
95
95
|
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
96
96
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
97
|
-
|
|
98
|
-
On success, the response text is empty (or contains only warnings if present). {{AUTO_READ_GUIDANCE}}
|
|
99
|
-
|
|
97
|
+
On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
|
|
100
98
|
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
101
99
|
|
|
102
100
|
Wrong: To replace lines 2-3 in this function:
|
|
@@ -129,3 +127,5 @@ Error recovery:
|
|
|
129
127
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
130
128
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
131
129
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
130
|
+
|
|
131
|
+
**Undo:** If a replace produced incorrect results, call `last_replace_undo` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
|
package/prompts/replace-flat.md
CHANGED
|
@@ -79,7 +79,8 @@ Rules:
|
|
|
79
79
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
80
80
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
81
81
|
- The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
|
|
82
|
-
|
|
82
|
+
- `hash_range_inclusive` and `content_lines` must be native JSON values, not JSON strings. Do not serialize them — pass them as a proper array and array of strings respectively.
|
|
83
|
+
On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
|
|
83
84
|
|
|
84
85
|
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
85
86
|
|
|
@@ -112,3 +113,5 @@ Error recovery:
|
|
|
112
113
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
113
114
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
114
115
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
116
|
+
|
|
117
|
+
**Undo:** If a replace produced incorrect results, call `last_replace_undo` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
|
package/prompts/replace.md
CHANGED
|
@@ -142,7 +142,8 @@ Rules:
|
|
|
142
142
|
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
143
143
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
144
144
|
- The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
|
|
145
|
-
|
|
145
|
+
- `changes` (in bulk mode), `hash_range_inclusive`, and `content_lines` must be native JSON values, not JSON strings. Do not serialize them — pass them as proper arrays and strings.
|
|
146
|
+
On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
|
|
146
147
|
|
|
147
148
|
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
148
149
|
|
|
@@ -176,3 +177,5 @@ Error recovery:
|
|
|
176
177
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
177
178
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
178
179
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
180
|
+
|
|
181
|
+
**Undo:** If a replace produced incorrect results, call `last_replace_undo` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
|
package/src/file-reader.ts
CHANGED
|
@@ -1,58 +1,60 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
2
|
import { lineHashes } from "./hashline";
|
|
3
3
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
4
|
+
import { resolveTarget } from "./fs-write";
|
|
4
5
|
import { toCwd } from "./path-utils";
|
|
5
6
|
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
6
7
|
import { abortIf } from "./runtime";
|
|
7
8
|
import { assertText, valAccess } from "./validation";
|
|
8
9
|
|
|
9
10
|
export interface NormFile {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
absolutePath: string;
|
|
12
|
+
normalized: string;
|
|
13
|
+
bom: string;
|
|
14
|
+
originalEnding: "\r\n" | "\n";
|
|
15
|
+
fileHashes: string[];
|
|
16
|
+
hadUtf8DecodeErrors: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export async function readNormFile(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
path: string,
|
|
21
|
+
cwd: string,
|
|
22
|
+
signal: AbortSignal | undefined,
|
|
23
|
+
accessMode: number = constants.R_OK,
|
|
24
|
+
preloadedFile?: LFile,
|
|
25
|
+
maxLines?: number,
|
|
25
26
|
): Promise<NormFile> {
|
|
26
|
-
|
|
27
|
+
const absolutePath = toCwd(path, cwd);
|
|
28
|
+
const resolvedPath = await resolveTarget(absolutePath);
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
abortIf(signal);
|
|
31
|
+
await valAccess(resolvedPath, path, accessMode);
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
abortIf(signal);
|
|
34
|
+
const file = preloadedFile ?? (await loadFileKindAndText(resolvedPath));
|
|
35
|
+
assertText(file, path);
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
abortIf(signal);
|
|
38
|
+
const { bom, text: rawContent } = stripBOM(file.text);
|
|
39
|
+
const originalEnding = detectEnding(rawContent);
|
|
40
|
+
const normalized = toLF(rawContent);
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
if (maxLines !== undefined) {
|
|
43
|
+
const lineCount = normalized.split("\n").length;
|
|
44
|
+
if (lineCount > maxLines) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
const fileHashes = await lineHashes(normalized, resolvedPath);
|
|
52
|
+
return {
|
|
53
|
+
absolutePath: resolvedPath,
|
|
54
|
+
normalized,
|
|
55
|
+
bom,
|
|
56
|
+
originalEnding,
|
|
57
|
+
fileHashes,
|
|
58
|
+
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
59
|
+
};
|
|
58
60
|
}
|
package/src/replace-normalize.ts
CHANGED
|
@@ -13,34 +13,56 @@ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | und
|
|
|
13
13
|
* Coerces an array of edit items: JSON-string items → objects,
|
|
14
14
|
* JSON-string content_lines → string arrays. Shared by the `changes`
|
|
15
15
|
* and `edits` normalization branches.
|
|
16
|
+
* Returns a warning if any item was a JSON string.
|
|
16
17
|
*/
|
|
17
|
-
function coerceEditArray(items: unknown[]): unknown[] {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
|
|
19
|
+
const warnings: string[] = [];
|
|
20
|
+
const result = items
|
|
21
|
+
.map((item: unknown) => {
|
|
22
|
+
if (typeof item === "string") {
|
|
23
|
+
const parsed = tryParseJSON(item, isRec);
|
|
24
|
+
if (parsed) {
|
|
25
|
+
warnings.push("Edit item was passed as a JSON string instead of a native object. Use native JSON values, not serialized strings.");
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return item;
|
|
30
|
+
})
|
|
20
31
|
.map((change: unknown) => {
|
|
21
32
|
if (!isRec(change)) return change;
|
|
22
33
|
if (typeof change.content_lines !== "string") return change;
|
|
23
34
|
const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
|
|
24
35
|
Array.isArray(v) && v.every((i) => typeof i === "string"),
|
|
25
36
|
);
|
|
26
|
-
|
|
37
|
+
if (parsed) {
|
|
38
|
+
warnings.push("content_lines was passed as a JSON string inside an edit item. Use a native array of strings.");
|
|
39
|
+
return { ...change, content_lines: parsed };
|
|
40
|
+
}
|
|
41
|
+
return change;
|
|
27
42
|
});
|
|
43
|
+
return { result, warnings };
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
/**
|
|
31
47
|
* Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
|
|
32
48
|
* single objects → wrapped in array. Shared by the `changes` and `edits`
|
|
33
49
|
* normalization branches.
|
|
50
|
+
* Returns a warning if the field was a JSON string.
|
|
34
51
|
*/
|
|
35
52
|
function normalizeField(
|
|
36
53
|
record: Record<string, unknown>,
|
|
37
54
|
from: string,
|
|
38
55
|
to: string,
|
|
39
|
-
):
|
|
40
|
-
if (!has(record, from)) return;
|
|
56
|
+
): string | undefined {
|
|
57
|
+
if (!has(record, from)) return undefined;
|
|
41
58
|
const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
|
|
59
|
+
const wasString = typeof record[from] === "string" && raw !== record[from];
|
|
42
60
|
if (Array.isArray(raw)) {
|
|
43
|
-
|
|
61
|
+
const { result, warnings } = coerceEditArray(raw);
|
|
62
|
+
record[to] = result;
|
|
63
|
+
if (warnings.length > 0) {
|
|
64
|
+
return warnings.join(" ");
|
|
65
|
+
}
|
|
44
66
|
} else {
|
|
45
67
|
const single =
|
|
46
68
|
typeof raw === "string"
|
|
@@ -48,9 +70,19 @@ function normalizeField(
|
|
|
48
70
|
: isRec(raw)
|
|
49
71
|
? raw
|
|
50
72
|
: undefined;
|
|
51
|
-
if (single)
|
|
73
|
+
if (single) {
|
|
74
|
+
const { result, warnings } = coerceEditArray([single]);
|
|
75
|
+
record[to] = result;
|
|
76
|
+
if (warnings.length > 0) {
|
|
77
|
+
return warnings.join(" ");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
52
80
|
}
|
|
53
81
|
if (from !== to) delete record[from];
|
|
82
|
+
if (wasString) {
|
|
83
|
+
return `Field "${from}" was passed as a JSON string instead of a native array. Use native JSON values, not serialized strings.`;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
54
86
|
}
|
|
55
87
|
|
|
56
88
|
export function normReq(input: unknown): unknown {
|
|
@@ -59,25 +91,36 @@ export function normReq(input: unknown): unknown {
|
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
const record: Record<string, unknown> = { ...input };
|
|
94
|
+
const warnings: string[] = [];
|
|
62
95
|
|
|
63
96
|
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
64
97
|
record.path = record.file_path;
|
|
65
98
|
delete record.file_path;
|
|
66
99
|
}
|
|
67
100
|
|
|
68
|
-
normalizeField(record, "changes", "changes");
|
|
69
|
-
|
|
101
|
+
const w1 = normalizeField(record, "changes", "changes");
|
|
102
|
+
if (w1) warnings.push(w1);
|
|
103
|
+
const w2 = normalizeField(record, "edits", "changes");
|
|
104
|
+
if (w2) warnings.push(w2);
|
|
70
105
|
|
|
71
106
|
// Handle flat format: hash_range_inclusive and content_lines at top level
|
|
72
107
|
// (no changes array). Wrap them into a single-element changes array.
|
|
73
108
|
if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
|
|
74
|
-
const
|
|
109
|
+
const hriRaw = record.hash_range_inclusive;
|
|
110
|
+
const hri = tryParseJSON(hriRaw, (v): v is string[] =>
|
|
75
111
|
Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
|
|
76
|
-
) ??
|
|
112
|
+
) ?? hriRaw;
|
|
113
|
+
if (typeof hriRaw === "string" && hri !== hriRaw) {
|
|
114
|
+
warnings.push("hash_range_inclusive was passed as a JSON string instead of a native array. Use native JSON values.");
|
|
115
|
+
}
|
|
77
116
|
|
|
78
|
-
const
|
|
117
|
+
const clRaw = record.content_lines;
|
|
118
|
+
const cl = tryParseJSON(clRaw, (v): v is string[] =>
|
|
79
119
|
Array.isArray(v) && v.every((i) => typeof i === "string")
|
|
80
|
-
) ??
|
|
120
|
+
) ?? clRaw;
|
|
121
|
+
if (typeof clRaw === "string" && cl !== clRaw) {
|
|
122
|
+
warnings.push("content_lines was passed as a JSON string instead of a native array. Use native JSON values.");
|
|
123
|
+
}
|
|
81
124
|
|
|
82
125
|
if (Array.isArray(hri) && Array.isArray(cl)) {
|
|
83
126
|
record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
|
|
@@ -86,6 +129,9 @@ export function normReq(input: unknown): unknown {
|
|
|
86
129
|
}
|
|
87
130
|
}
|
|
88
131
|
|
|
132
|
+
if (warnings.length > 0) {
|
|
133
|
+
(record as Record<string, unknown>)._normWarnings = warnings;
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
return record;
|
|
90
137
|
}
|
|
91
|
-
|
package/src/replace-response.ts
CHANGED
|
@@ -148,11 +148,14 @@ export function buildChanged(input: SuccessInput): TResult {
|
|
|
148
148
|
const removedLines = cntDiff(diffResult.diff, "-");
|
|
149
149
|
const warningsBlock = warnBlock(warnings);
|
|
150
150
|
const successPrefix = `Successfully replaced in ${path}.`;
|
|
151
|
+
const lineSummary = addedLines > 0 || removedLines > 0
|
|
152
|
+
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
153
|
+
: "";
|
|
151
154
|
const text = resultLines.length === 0
|
|
152
155
|
? "File is empty. Use replace to insert content."
|
|
153
156
|
: warningsBlock
|
|
154
|
-
? `${successPrefix}${warningsBlock}`
|
|
155
|
-
: successPrefix
|
|
157
|
+
? `${successPrefix}${lineSummary}${warningsBlock}`
|
|
158
|
+
: `${successPrefix}${lineSummary}`;
|
|
156
159
|
|
|
157
160
|
const metrics = buildM({
|
|
158
161
|
classification: "applied",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { getUndo, clearUndo } from "./undo-store";
|
|
6
|
+
import { loadHashStore, saveHashStore } from "./hash-store";
|
|
7
|
+
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
|
+
import { toCwd } from "./path-utils";
|
|
9
|
+
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
10
|
+
|
|
11
|
+
function cntDiff(diff: string, marker: "+" | "-"): number {
|
|
12
|
+
if (!diff) return 0;
|
|
13
|
+
let count = 0;
|
|
14
|
+
for (const line of diff.split("\n")) {
|
|
15
|
+
if (
|
|
16
|
+
line.startsWith(marker) &&
|
|
17
|
+
!line.startsWith(`${marker}${marker}${marker}`)
|
|
18
|
+
) {
|
|
19
|
+
count += 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return count;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
26
|
+
pi.registerTool({
|
|
27
|
+
name: "last_replace_undo",
|
|
28
|
+
label: "Undo Last Replace",
|
|
29
|
+
description:
|
|
30
|
+
"Undo the last replace operation on a file, reverting it to its previous state. " +
|
|
31
|
+
"Use this when a replace produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). " +
|
|
32
|
+
"After undoing, call `read` to get fresh anchors for a corrected replace.",
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
path: Type.String({
|
|
35
|
+
description: "Path to the file to undo the last replace on",
|
|
36
|
+
}),
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
40
|
+
const path = params.path;
|
|
41
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
42
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
43
|
+
|
|
44
|
+
const undo = getUndo(mutationTargetPath);
|
|
45
|
+
if (!undo) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: `No undo history for ${path}. There is no previous replace to revert.`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
isError: true,
|
|
54
|
+
details: {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
59
|
+
// Read current file content to compute diff against the pre-replace state
|
|
60
|
+
let currentNormalized = "";
|
|
61
|
+
try {
|
|
62
|
+
const currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
63
|
+
const { text: currentStripped } = stripBOM(currentRaw);
|
|
64
|
+
currentNormalized = toLF(currentStripped);
|
|
65
|
+
} catch {
|
|
66
|
+
// File may have been deleted; treat as empty
|
|
67
|
+
currentNormalized = "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Compute diff: old = pre-replace (undo.content), new = current
|
|
71
|
+
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
|
72
|
+
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
73
|
+
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
74
|
+
|
|
75
|
+
// Write the pre-edit content back to the file
|
|
76
|
+
await writeAtomic(
|
|
77
|
+
mutationTargetPath,
|
|
78
|
+
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Restore the hash store snapshot so the next read returns the same hashes
|
|
82
|
+
const store = await loadHashStore();
|
|
83
|
+
store.snapshots[mutationTargetPath] = {
|
|
84
|
+
content: undo.content,
|
|
85
|
+
hashes: undo.hashes,
|
|
86
|
+
};
|
|
87
|
+
await saveHashStore(store);
|
|
88
|
+
|
|
89
|
+
// Clear undo so a second call without an intervening replace is a no-op
|
|
90
|
+
clearUndo(mutationTargetPath);
|
|
91
|
+
|
|
92
|
+
const parts: string[] = [
|
|
93
|
+
`Undone last replace on ${path}.`,
|
|
94
|
+
];
|
|
95
|
+
if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
|
|
96
|
+
parts.push(
|
|
97
|
+
`Removed ${linesAddedByReplace} line(s) that were added and restored ${linesRemovedByReplace} line(s) that were removed.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
parts.push(
|
|
101
|
+
"File reverted to previous state. Call `read` to get fresh anchors for follow-up edits.",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
{
|
|
107
|
+
type: "text",
|
|
108
|
+
text: parts.join("\n"),
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
details: {
|
|
112
|
+
metrics: {
|
|
113
|
+
added_lines: linesRemovedByReplace,
|
|
114
|
+
removed_lines: linesAddedByReplace,
|
|
115
|
+
classification: "applied" as const,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
package/src/replace.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "./replace-render";
|
|
45
45
|
import { loadP, loadGuide } from "./prompts";
|
|
46
46
|
import { readAutoReadSync } from "./config";
|
|
47
|
+
import { saveUndo } from "./undo-store";
|
|
47
48
|
|
|
48
49
|
const contentLinesSchema = Type.Array(Type.String(), {
|
|
49
50
|
description:
|
|
@@ -168,6 +169,7 @@ export async function execPipeline(
|
|
|
168
169
|
);
|
|
169
170
|
|
|
170
171
|
const absolutePath = toCwd(path, cwd);
|
|
172
|
+
const resolvedPath = await resolveTarget(absolutePath);
|
|
171
173
|
const resolved = resEdits(toolEdits);
|
|
172
174
|
const anchorResult = applyEdits(
|
|
173
175
|
originalNormalized,
|
|
@@ -199,7 +201,7 @@ export async function execPipeline(
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
// Compute stable result hashes using hash-aware preservation
|
|
202
|
-
const resultHashes = await lineHashes(result,
|
|
204
|
+
const resultHashes = await lineHashes(result, resolvedPath, {
|
|
203
205
|
content: originalNormalized,
|
|
204
206
|
hashes: originalHashes,
|
|
205
207
|
removedHashes,
|
|
@@ -437,6 +439,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
437
439
|
}],
|
|
438
440
|
})
|
|
439
441
|
: normReq(params);
|
|
442
|
+
const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
|
|
440
443
|
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
441
444
|
const path = normalizedParams.path;
|
|
442
445
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
@@ -469,6 +472,10 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
469
472
|
? normalizedParams.changes.length
|
|
470
473
|
: 0;
|
|
471
474
|
|
|
475
|
+
if (normWarnings) {
|
|
476
|
+
warnings.push(...normWarnings);
|
|
477
|
+
}
|
|
478
|
+
|
|
472
479
|
if (originalNormalized === result) {
|
|
473
480
|
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
474
481
|
return buildNoop({
|
|
@@ -494,6 +501,12 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
494
501
|
absolutePath,
|
|
495
502
|
bom + restoreEndings(result, originalEnding),
|
|
496
503
|
);
|
|
504
|
+
saveUndo(mutationTargetPath, {
|
|
505
|
+
content: originalNormalized,
|
|
506
|
+
bom,
|
|
507
|
+
originalEnding,
|
|
508
|
+
hashes: originalHashes,
|
|
509
|
+
});
|
|
497
510
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
498
511
|
.snapshotId;
|
|
499
512
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory undo store for the last_replace_undo tool.
|
|
3
|
+
*
|
|
4
|
+
* Before each successful replace, the pre-edit file state (normalized content,
|
|
5
|
+
* BOM, original line ending, and hashes) is saved keyed by absolute path.
|
|
6
|
+
* The undo tool reads this entry, restores the file, and clears the entry.
|
|
7
|
+
*
|
|
8
|
+
* Only the most recent replace per file is tracked — calling undo twice
|
|
9
|
+
* without an intervening replace will produce "no undo history".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface UndoEntry {
|
|
13
|
+
/** Normalized (LF) content before the edit */
|
|
14
|
+
content: string;
|
|
15
|
+
/** BOM prefix that was stripped from the original file */
|
|
16
|
+
bom: string;
|
|
17
|
+
/** Original line ending detected before the edit */
|
|
18
|
+
originalEnding: "\r\n" | "\n";
|
|
19
|
+
/** Hash array for the pre-edit content */
|
|
20
|
+
hashes: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const undoMap = new Map<string, UndoEntry>();
|
|
24
|
+
|
|
25
|
+
export function saveUndo(path: string, entry: UndoEntry): void {
|
|
26
|
+
undoMap.set(path, entry);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getUndo(path: string): UndoEntry | undefined {
|
|
30
|
+
return undoMap.get(path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearUndo(path: string): void {
|
|
34
|
+
undoMap.delete(path);
|
|
35
|
+
}
|