pi-hashline-edit-pro 0.15.4 → 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-flat.ts +7 -303
- 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 +91 -21
- 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-flat.ts
CHANGED
|
@@ -1,313 +1,17 @@
|
|
|
1
|
-
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
1
|
import type {
|
|
3
2
|
ExtensionAPI,
|
|
4
|
-
ToolDefinition,
|
|
5
3
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Type } from "typebox";
|
|
8
|
-
import { constants } from "fs";
|
|
9
4
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./replace
|
|
13
|
-
import { readNormFile } from "./file-reader";
|
|
14
|
-
import { normReq } from "./replace-normalize";
|
|
15
|
-
import { isRec } from "./utils";
|
|
16
|
-
import { MAX_HASH_LINES } from "./constants";
|
|
17
|
-
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
|
-
import {
|
|
19
|
-
applyEdits,
|
|
20
|
-
resEdits,
|
|
21
|
-
type HTEdit,
|
|
22
|
-
} from "./hashline";
|
|
23
|
-
import { toCwd } from "./path-utils";
|
|
24
|
-
import { abortIf } from "./runtime";
|
|
25
|
-
import { fileSnap } from "./snapshot";
|
|
26
|
-
import {
|
|
27
|
-
buildChanged,
|
|
28
|
-
buildNoop,
|
|
29
|
-
type RMeta,
|
|
30
|
-
type RMetrics,
|
|
31
|
-
} from "./replace-response";
|
|
32
|
-
import {
|
|
33
|
-
buildAppliedText,
|
|
34
|
-
mkMdTheme,
|
|
35
|
-
fmtCall,
|
|
36
|
-
fmtResultMd,
|
|
37
|
-
getPreviewInput,
|
|
38
|
-
getResultText,
|
|
39
|
-
isApplied,
|
|
40
|
-
type RPreview,
|
|
41
|
-
type RRState,
|
|
42
|
-
} from "./replace-render";
|
|
43
|
-
import { loadP, loadGuide } from "./prompts";
|
|
44
|
-
import { execPipeline, compPreview, type ReplaceDetails } from "./replace";
|
|
45
|
-
import { readAutoReadSync } from "./config";
|
|
46
|
-
|
|
47
|
-
const contentLinesSchema = Type.Array(Type.String(), {
|
|
48
|
-
description:
|
|
49
|
-
"literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const hashRangeInclSchema = Type.Array(
|
|
53
|
-
Type.String({ description: "anchor (3-char HASH)" }),
|
|
54
|
-
{
|
|
55
|
-
description: "inclusive hash 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.",
|
|
56
|
-
minItems: 2,
|
|
57
|
-
maxItems: 2,
|
|
58
|
-
},
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Flat-mode schema: hash_range_inclusive and content_lines are at the top
|
|
63
|
-
* level instead of inside a "changes" array. Only a single edit is supported
|
|
64
|
-
* per call (no bulk changes).
|
|
65
|
-
*/
|
|
66
|
-
export const flatEditToolSchema = Type.Object(
|
|
67
|
-
{
|
|
68
|
-
path: Type.String({ description: "path" }),
|
|
69
|
-
hash_range_inclusive: hashRangeInclSchema,
|
|
70
|
-
content_lines: contentLinesSchema,
|
|
71
|
-
},
|
|
72
|
-
{ additionalProperties: false },
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
type ToolDef = ToolDefinition<
|
|
76
|
-
typeof flatEditToolSchema,
|
|
77
|
-
ReplaceDetails,
|
|
78
|
-
RRState
|
|
79
|
-
> & { renderShell?: "default" | "self" };
|
|
80
|
-
|
|
81
|
-
function reuseText(context: any, content: string): Text {
|
|
82
|
-
const t = context.lastComponent instanceof Text
|
|
83
|
-
? context.lastComponent
|
|
84
|
-
: new Text("", 0, 0);
|
|
85
|
-
t.setText(content);
|
|
86
|
-
return t;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
90
|
-
const m = context.lastComponent instanceof Markdown
|
|
91
|
-
? context.lastComponent
|
|
92
|
-
: new Markdown("", 0, 0, mkMdTheme(theme));
|
|
93
|
-
m.setText(content);
|
|
94
|
-
return m;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function buildToolDef(): ToolDef {
|
|
98
|
-
const autoRead = readAutoReadSync();
|
|
99
|
-
const readGuidance = autoRead
|
|
100
|
-
? "Anchors are provided automatically after write operations when auto-read is enabled."
|
|
101
|
-
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
102
|
-
|
|
103
|
-
const E_DESC = loadP("../prompts/replace-flat.md", {
|
|
104
|
-
AUTO_READ_GUIDANCE: readGuidance,
|
|
105
|
-
});
|
|
106
|
-
const E_SNIPPET = loadP("../prompts/replace-flat-snippet.md");
|
|
107
|
-
const E_GUIDE = loadGuide("../prompts/replace-flat-guidelines.md", {
|
|
108
|
-
AUTO_READ_GUIDANCE: readGuidance,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
name: "replace",
|
|
113
|
-
label: "Replace",
|
|
114
|
-
description: E_DESC,
|
|
115
|
-
parameters: flatEditToolSchema,
|
|
116
|
-
promptSnippet: E_SNIPPET,
|
|
117
|
-
promptGuidelines: E_GUIDE,
|
|
118
|
-
prepareArguments: (args: unknown) => {
|
|
119
|
-
// Minimal normalization: file_path → path, JSON string parsing.
|
|
120
|
-
// The flat-to-canonical conversion happens in execute().
|
|
121
|
-
if (!isRec(args)) return args as any;
|
|
122
|
-
const record = { ...args };
|
|
123
|
-
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
124
|
-
record.path = record.file_path;
|
|
125
|
-
delete record.file_path;
|
|
126
|
-
}
|
|
127
|
-
if (typeof record.hash_range_inclusive === "string") {
|
|
128
|
-
try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
|
|
129
|
-
}
|
|
130
|
-
if (typeof record.content_lines === "string") {
|
|
131
|
-
try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
|
|
132
|
-
}
|
|
133
|
-
return record as any;
|
|
134
|
-
},
|
|
135
|
-
renderShell: "default",
|
|
136
|
-
renderCall(args, theme, context) {
|
|
137
|
-
const previewInput = getPreviewInput(args);
|
|
138
|
-
if (context.executionStarted) {
|
|
139
|
-
context.state.argsKey = undefined;
|
|
140
|
-
context.state.preview = undefined;
|
|
141
|
-
context.state.previewGeneration =
|
|
142
|
-
(context.state.previewGeneration ?? 0) + 1;
|
|
143
|
-
} else if (!context.argsComplete || !previewInput) {
|
|
144
|
-
context.state.argsKey = undefined;
|
|
145
|
-
context.state.preview = undefined;
|
|
146
|
-
context.state.previewGeneration =
|
|
147
|
-
(context.state.previewGeneration ?? 0) + 1;
|
|
148
|
-
} else {
|
|
149
|
-
const argsKey = JSON.stringify(previewInput);
|
|
150
|
-
if (context.state.argsKey !== argsKey) {
|
|
151
|
-
context.state.argsKey = argsKey;
|
|
152
|
-
context.state.preview = undefined;
|
|
153
|
-
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
154
|
-
context.state.previewGeneration = previewGeneration;
|
|
155
|
-
compPreview(previewInput, context.cwd)
|
|
156
|
-
.then((preview) => {
|
|
157
|
-
if (
|
|
158
|
-
context.state.argsKey === argsKey &&
|
|
159
|
-
context.state.previewGeneration === previewGeneration
|
|
160
|
-
) {
|
|
161
|
-
context.state.preview = preview;
|
|
162
|
-
context.invalidate();
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
.catch((err: unknown) => {
|
|
166
|
-
if (
|
|
167
|
-
context.state.argsKey === argsKey &&
|
|
168
|
-
context.state.previewGeneration === previewGeneration
|
|
169
|
-
) {
|
|
170
|
-
context.state.preview = {
|
|
171
|
-
error: err instanceof Error ? err.message : String(err),
|
|
172
|
-
};
|
|
173
|
-
context.invalidate();
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
const text =
|
|
179
|
-
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
180
|
-
text.setText(
|
|
181
|
-
fmtCall(
|
|
182
|
-
getPreviewInput(args) ?? undefined,
|
|
183
|
-
context.state as RRState,
|
|
184
|
-
context.expanded,
|
|
185
|
-
theme,
|
|
186
|
-
),
|
|
187
|
-
);
|
|
188
|
-
return text;
|
|
189
|
-
},
|
|
190
|
-
|
|
191
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
192
|
-
if (isPartial) {
|
|
193
|
-
return reuseText(context, theme.fg("warning", "Editing..."));
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const typedResult = result as {
|
|
197
|
-
content?: Array<{ type: string; text?: string }>;
|
|
198
|
-
details?: ReplaceDetails;
|
|
199
|
-
};
|
|
200
|
-
const renderedText = getResultText(typedResult);
|
|
201
|
-
|
|
202
|
-
const renderState = context.state as RRState | undefined;
|
|
203
|
-
if (renderState) {
|
|
204
|
-
renderState.preview = undefined;
|
|
205
|
-
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (context.isError) {
|
|
209
|
-
return renderedText
|
|
210
|
-
? reuseText(context, `\n${theme.fg("error", renderedText)}`)
|
|
211
|
-
: new Text("", 0, 0);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (isApplied(typedResult.details)) {
|
|
215
|
-
const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
|
|
216
|
-
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (!renderedText) return new Text("", 0, 0);
|
|
220
|
-
return reuseMarkdown(context, fmtResultMd(renderedText), theme);
|
|
221
|
-
},
|
|
222
|
-
|
|
223
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
224
|
-
// Wrap flat params into canonical shape for the pipeline
|
|
225
|
-
const canonical = normReq({
|
|
226
|
-
path: params.path,
|
|
227
|
-
changes: [{
|
|
228
|
-
hash_range_inclusive: params.hash_range_inclusive,
|
|
229
|
-
content_lines: params.content_lines,
|
|
230
|
-
}],
|
|
231
|
-
});
|
|
232
|
-
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
233
|
-
const path = normalizedParams.path;
|
|
234
|
-
const absolutePath = toCwd(path, ctx.cwd);
|
|
235
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
236
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
237
|
-
abortIf(signal);
|
|
238
|
-
|
|
239
|
-
const {
|
|
240
|
-
originalNormalized,
|
|
241
|
-
originalHashes,
|
|
242
|
-
result,
|
|
243
|
-
bom,
|
|
244
|
-
originalEnding,
|
|
245
|
-
hadUtf8DecodeErrors,
|
|
246
|
-
warnings,
|
|
247
|
-
noopEdits,
|
|
248
|
-
firstChangedLine,
|
|
249
|
-
lastChangedLine,
|
|
250
|
-
resultHashes,
|
|
251
|
-
} = await execPipeline(
|
|
252
|
-
normalizedParams,
|
|
253
|
-
ctx.cwd,
|
|
254
|
-
constants.R_OK | constants.W_OK,
|
|
255
|
-
signal,
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
const editsAttempted = 1; // flat mode: exactly one edit per call
|
|
259
|
-
|
|
260
|
-
if (originalNormalized === result) {
|
|
261
|
-
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
262
|
-
return buildNoop({
|
|
263
|
-
path,
|
|
264
|
-
noopEdits,
|
|
265
|
-
snapshotId: noopSnapshotId,
|
|
266
|
-
editMeta: {
|
|
267
|
-
editsAttempted,
|
|
268
|
-
noopEditsCount: noopEdits?.length ?? 0,
|
|
269
|
-
},
|
|
270
|
-
warnings,
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (hadUtf8DecodeErrors) {
|
|
275
|
-
warnings.push(
|
|
276
|
-
"Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
abortIf(signal);
|
|
281
|
-
await writeAtomic(
|
|
282
|
-
absolutePath,
|
|
283
|
-
bom + restoreEndings(result, originalEnding),
|
|
284
|
-
);
|
|
285
|
-
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
286
|
-
.snapshotId;
|
|
5
|
+
buildToolDef,
|
|
6
|
+
flatEditToolSchema,
|
|
7
|
+
} from "./replace";
|
|
287
8
|
|
|
288
|
-
|
|
289
|
-
editsAttempted,
|
|
290
|
-
noopEditsCount: noopEdits?.length ?? 0,
|
|
291
|
-
firstChangedLine,
|
|
292
|
-
lastChangedLine,
|
|
293
|
-
};
|
|
9
|
+
export { flatEditToolSchema };
|
|
294
10
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
originalNormalized,
|
|
298
|
-
originalHashes,
|
|
299
|
-
result,
|
|
300
|
-
resultHashes,
|
|
301
|
-
warnings,
|
|
302
|
-
snapshotId: updatedSnapshotId,
|
|
303
|
-
editMeta,
|
|
304
|
-
};
|
|
305
|
-
return buildChanged(successInput);
|
|
306
|
-
});
|
|
307
|
-
},
|
|
308
|
-
};
|
|
11
|
+
export function buildToolDefFlat() {
|
|
12
|
+
return buildToolDef({ flat: true });
|
|
309
13
|
}
|
|
310
14
|
|
|
311
15
|
export function regReplaceFlat(pi: ExtensionAPI): void {
|
|
312
|
-
pi.registerTool(buildToolDef());
|
|
16
|
+
pi.registerTool(buildToolDef({ flat: true }));
|
|
313
17
|
}
|
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:
|
|
@@ -75,6 +76,20 @@ export const editToolSchema = Type.Object(
|
|
|
75
76
|
{ additionalProperties: false },
|
|
76
77
|
);
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Flat-mode schema: hash_range_inclusive and content_lines are at the top
|
|
81
|
+
* level instead of inside a "changes" array. Only a single edit is supported
|
|
82
|
+
* per call (no bulk changes).
|
|
83
|
+
*/
|
|
84
|
+
export const flatEditToolSchema = Type.Object(
|
|
85
|
+
{
|
|
86
|
+
path: Type.String({ description: "path" }),
|
|
87
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
88
|
+
content_lines: contentLinesSchema,
|
|
89
|
+
},
|
|
90
|
+
{ additionalProperties: false },
|
|
91
|
+
);
|
|
92
|
+
|
|
78
93
|
export type ReqParams = {
|
|
79
94
|
path: string;
|
|
80
95
|
changes: HTEdit[];
|
|
@@ -154,6 +169,7 @@ export async function execPipeline(
|
|
|
154
169
|
);
|
|
155
170
|
|
|
156
171
|
const absolutePath = toCwd(path, cwd);
|
|
172
|
+
const resolvedPath = await resolveTarget(absolutePath);
|
|
157
173
|
const resolved = resEdits(toolEdits);
|
|
158
174
|
const anchorResult = applyEdits(
|
|
159
175
|
originalNormalized,
|
|
@@ -165,15 +181,27 @@ export async function execPipeline(
|
|
|
165
181
|
|
|
166
182
|
const result = anchorResult.content;
|
|
167
183
|
|
|
168
|
-
// Collect hashes targeted by the edit for hash-aware diff disambiguation
|
|
184
|
+
// Collect hashes targeted by the edit for hash-aware diff disambiguation.
|
|
185
|
+
// Include every hash in the replaced range, not just the boundary anchors,
|
|
186
|
+
// so that a surviving line whose content is identical to an interior line
|
|
187
|
+
// of the edit is not matched to a removed hash.
|
|
188
|
+
// Use originalHashes.indexOf to find line numbers since resEdits returns
|
|
189
|
+
// HEdit[] (Anchor = { hash }) not RHEdit[] (RAnchor = { line, hash }).
|
|
169
190
|
const removedHashes = new Set<string>();
|
|
170
191
|
for (const edit of resolved) {
|
|
171
|
-
|
|
172
|
-
|
|
192
|
+
const startHash = edit.hash_range_inclusive[0].hash;
|
|
193
|
+
const endHash = edit.hash_range_inclusive[1].hash;
|
|
194
|
+
const startLine = originalHashes.indexOf(startHash);
|
|
195
|
+
const endLine = originalHashes.indexOf(endHash);
|
|
196
|
+
if (startLine >= 0 && endLine >= 0) {
|
|
197
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
198
|
+
removedHashes.add(originalHashes[i]!);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
173
201
|
}
|
|
174
202
|
|
|
175
203
|
// Compute stable result hashes using hash-aware preservation
|
|
176
|
-
const resultHashes = await lineHashes(result,
|
|
204
|
+
const resultHashes = await lineHashes(result, resolvedPath, {
|
|
177
205
|
content: originalNormalized,
|
|
178
206
|
hashes: originalHashes,
|
|
179
207
|
removedHashes,
|
|
@@ -247,12 +275,12 @@ export async function compPreview(
|
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
type ToolDef = ToolDefinition<
|
|
250
|
-
|
|
278
|
+
any,
|
|
251
279
|
ReplaceDetails,
|
|
252
280
|
RRState
|
|
253
281
|
> & { renderShell?: "default" | "self" };
|
|
254
282
|
|
|
255
|
-
function reuseText(context: any, content: string): Text {
|
|
283
|
+
export function reuseText(context: any, content: string): Text {
|
|
256
284
|
const t = context.lastComponent instanceof Text
|
|
257
285
|
? context.lastComponent
|
|
258
286
|
: new Text("", 0, 0);
|
|
@@ -260,7 +288,7 @@ function reuseText(context: any, content: string): Text {
|
|
|
260
288
|
return t;
|
|
261
289
|
}
|
|
262
290
|
|
|
263
|
-
function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
291
|
+
export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
264
292
|
const m = context.lastComponent instanceof Markdown
|
|
265
293
|
? context.lastComponent
|
|
266
294
|
: new Markdown("", 0, 0, mkMdTheme(theme));
|
|
@@ -268,29 +296,49 @@ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
|
268
296
|
return m;
|
|
269
297
|
}
|
|
270
298
|
|
|
271
|
-
export function buildToolDef(): ToolDef {
|
|
299
|
+
export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
272
300
|
const autoRead = readAutoReadSync();
|
|
273
301
|
const readGuidance = autoRead
|
|
274
302
|
? "Anchors are provided automatically after write operations when auto-read is enabled."
|
|
275
303
|
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
276
304
|
|
|
277
|
-
const E_DESC = loadP("../prompts/replace-bulk.md", {
|
|
305
|
+
const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
|
|
278
306
|
AUTO_READ_GUIDANCE: readGuidance,
|
|
279
307
|
});
|
|
280
|
-
const E_SNIPPET = loadP("../prompts/replace-bulk-snippet.md");
|
|
281
|
-
const E_GUIDE = loadGuide("../prompts/replace-bulk-guidelines.md", {
|
|
308
|
+
const E_SNIPPET = loadP(opts.flat ? "../prompts/replace-flat-snippet.md" : "../prompts/replace-bulk-snippet.md");
|
|
309
|
+
const E_GUIDE = loadGuide(opts.flat ? "../prompts/replace-flat-guidelines.md" : "../prompts/replace-bulk-guidelines.md", {
|
|
282
310
|
AUTO_READ_GUIDANCE: readGuidance,
|
|
283
311
|
});
|
|
284
312
|
|
|
313
|
+
const parameters = opts.flat ? flatEditToolSchema : editToolSchema;
|
|
314
|
+
|
|
285
315
|
return {
|
|
286
316
|
name: "replace",
|
|
287
317
|
label: "Replace",
|
|
288
318
|
description: E_DESC,
|
|
289
|
-
parameters
|
|
319
|
+
parameters,
|
|
290
320
|
promptSnippet: E_SNIPPET,
|
|
291
321
|
promptGuidelines: E_GUIDE,
|
|
292
|
-
prepareArguments:
|
|
293
|
-
|
|
322
|
+
prepareArguments: opts.flat
|
|
323
|
+
? (args: unknown) => {
|
|
324
|
+
// Minimal normalization: file_path → path, JSON string parsing.
|
|
325
|
+
// The flat-to-canonical conversion happens in execute().
|
|
326
|
+
if (!isRec(args)) return args as any;
|
|
327
|
+
const record = { ...args };
|
|
328
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
329
|
+
record.path = record.file_path;
|
|
330
|
+
delete record.file_path;
|
|
331
|
+
}
|
|
332
|
+
if (typeof record.hash_range_inclusive === "string") {
|
|
333
|
+
try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
|
|
334
|
+
}
|
|
335
|
+
if (typeof record.content_lines === "string") {
|
|
336
|
+
try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
|
|
337
|
+
}
|
|
338
|
+
return record as any;
|
|
339
|
+
}
|
|
340
|
+
: (args: unknown) =>
|
|
341
|
+
normReq(args) as ReqParams,
|
|
294
342
|
renderShell: "default",
|
|
295
343
|
renderCall(args, theme, context) {
|
|
296
344
|
const previewInput = getPreviewInput(args);
|
|
@@ -380,9 +428,19 @@ export function buildToolDef(): ToolDef {
|
|
|
380
428
|
},
|
|
381
429
|
|
|
382
430
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
431
|
+
// Flat mode: wrap top-level fields into a single-element changes array.
|
|
432
|
+
// Bulk mode: use params as-is after normReq normalization.
|
|
433
|
+
const canonical = opts.flat
|
|
434
|
+
? normReq({
|
|
435
|
+
path: (params as any).path,
|
|
436
|
+
changes: [{
|
|
437
|
+
hash_range_inclusive: (params as any).hash_range_inclusive,
|
|
438
|
+
content_lines: (params as any).content_lines,
|
|
439
|
+
}],
|
|
440
|
+
})
|
|
441
|
+
: normReq(params);
|
|
442
|
+
const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
|
|
443
|
+
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
386
444
|
const path = normalizedParams.path;
|
|
387
445
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
388
446
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
@@ -408,9 +466,15 @@ export function buildToolDef(): ToolDef {
|
|
|
408
466
|
signal,
|
|
409
467
|
);
|
|
410
468
|
|
|
411
|
-
const editsAttempted =
|
|
412
|
-
?
|
|
413
|
-
:
|
|
469
|
+
const editsAttempted = opts.flat
|
|
470
|
+
? 1
|
|
471
|
+
: Array.isArray(normalizedParams.changes)
|
|
472
|
+
? normalizedParams.changes.length
|
|
473
|
+
: 0;
|
|
474
|
+
|
|
475
|
+
if (normWarnings) {
|
|
476
|
+
warnings.push(...normWarnings);
|
|
477
|
+
}
|
|
414
478
|
|
|
415
479
|
if (originalNormalized === result) {
|
|
416
480
|
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
@@ -437,6 +501,12 @@ export function buildToolDef(): ToolDef {
|
|
|
437
501
|
absolutePath,
|
|
438
502
|
bom + restoreEndings(result, originalEnding),
|
|
439
503
|
);
|
|
504
|
+
saveUndo(mutationTargetPath, {
|
|
505
|
+
content: originalNormalized,
|
|
506
|
+
bom,
|
|
507
|
+
originalEnding,
|
|
508
|
+
hashes: originalHashes,
|
|
509
|
+
});
|
|
440
510
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
441
511
|
.snapshotId;
|
|
442
512
|
|
|
@@ -464,5 +534,5 @@ export function buildToolDef(): ToolDef {
|
|
|
464
534
|
}
|
|
465
535
|
|
|
466
536
|
export function regReplace(pi: ExtensionAPI): void {
|
|
467
|
-
pi.registerTool(buildToolDef());
|
|
537
|
+
pi.registerTool(buildToolDef({ flat: false }));
|
|
468
538
|
}
|
|
@@ -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
|
+
}
|