pi-hashline-edit-pro 0.13.3 → 0.14.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 +45 -12
- package/index.ts +47 -5
- package/package.json +1 -1
- package/prompts/replace-bulk-guidelines.md +4 -0
- package/prompts/replace-bulk-snippet.md +1 -0
- package/prompts/replace-bulk.md +130 -0
- package/prompts/replace-flat-guidelines.md +4 -0
- package/prompts/replace-flat-snippet.md +1 -0
- package/prompts/replace-flat.md +113 -0
- package/prompts/replace-guidelines.md +3 -2
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +49 -4
- package/src/config.ts +97 -0
- package/src/hashline/apply.ts +6 -4
- package/src/prompts.ts +19 -13
- package/src/replace-flat.ts +313 -0
- package/src/replace-normalize.ts +44 -24
- package/src/replace-response.ts +5 -2
- package/src/replace.ts +256 -241
package/src/config.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
export type ReplaceMode = "bulk" | "flat";
|
|
7
|
+
|
|
8
|
+
export interface Config {
|
|
9
|
+
replaceMode: ReplaceMode;
|
|
10
|
+
autoRead: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: Config = {
|
|
14
|
+
replaceMode: "bulk",
|
|
15
|
+
autoRead: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Compute config path lazily so tests can override HOME before calling. */
|
|
19
|
+
function configPath(): string {
|
|
20
|
+
return join(homedir(), ".config", "pi-hashline-edit-pro", "config.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function configDir(): string {
|
|
24
|
+
return join(homedir(), ".config", "pi-hashline-edit-pro");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readConfig(): Promise<Config> {
|
|
28
|
+
try {
|
|
29
|
+
const content = await readFile(configPath(), "utf-8");
|
|
30
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
31
|
+
return {
|
|
32
|
+
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
33
|
+
autoRead: parsed.autoRead === true,
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return { ...DEFAULT_CONFIG };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readConfigSync(): Config {
|
|
41
|
+
try {
|
|
42
|
+
const content = readFileSync(configPath(), "utf-8");
|
|
43
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
44
|
+
return {
|
|
45
|
+
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
46
|
+
autoRead: parsed.autoRead === true,
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return { ...DEFAULT_CONFIG };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function writeConfig(config: Config): Promise<void> {
|
|
54
|
+
await mkdir(configDir(), { recursive: true });
|
|
55
|
+
await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readReplaceMode(): Promise<ReplaceMode> {
|
|
59
|
+
const config = await readConfig();
|
|
60
|
+
return config.replaceMode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function writeReplaceMode(mode: ReplaceMode): Promise<void> {
|
|
64
|
+
const config = await readConfig();
|
|
65
|
+
config.replaceMode = mode;
|
|
66
|
+
await writeConfig(config);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function toggleReplaceMode(): Promise<ReplaceMode> {
|
|
70
|
+
const config = await readConfig();
|
|
71
|
+
config.replaceMode = config.replaceMode === "bulk" ? "flat" : "bulk";
|
|
72
|
+
await writeConfig(config);
|
|
73
|
+
return config.replaceMode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function readAutoRead(): Promise<boolean> {
|
|
77
|
+
const config = await readConfig();
|
|
78
|
+
return config.autoRead;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readAutoReadSync(): boolean {
|
|
82
|
+
const config = readConfigSync();
|
|
83
|
+
return config.autoRead;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function writeAutoRead(value: boolean): Promise<void> {
|
|
87
|
+
const config = await readConfig();
|
|
88
|
+
config.autoRead = value;
|
|
89
|
+
await writeConfig(config);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function toggleAutoRead(): Promise<boolean> {
|
|
93
|
+
const config = await readConfig();
|
|
94
|
+
config.autoRead = !config.autoRead;
|
|
95
|
+
await writeConfig(config);
|
|
96
|
+
return config.autoRead;
|
|
97
|
+
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -92,11 +92,13 @@ function resToSpan(
|
|
|
92
92
|
return null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const label = descEdit(edit);
|
|
96
|
+
|
|
95
97
|
if (edit.content_lines.length > 0) {
|
|
96
98
|
return {
|
|
97
99
|
kind: "replace",
|
|
98
100
|
index,
|
|
99
|
-
label
|
|
101
|
+
label,
|
|
100
102
|
start: lineStarts[startLine - 1]!,
|
|
101
103
|
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
102
104
|
replacement: edit.content_lines.join("\n"),
|
|
@@ -107,7 +109,7 @@ function resToSpan(
|
|
|
107
109
|
return {
|
|
108
110
|
kind: "replace",
|
|
109
111
|
index,
|
|
110
|
-
label
|
|
112
|
+
label,
|
|
111
113
|
start: 0,
|
|
112
114
|
end: content.length,
|
|
113
115
|
replacement: "",
|
|
@@ -118,7 +120,7 @@ function resToSpan(
|
|
|
118
120
|
return {
|
|
119
121
|
kind: "replace",
|
|
120
122
|
index,
|
|
121
|
-
label
|
|
123
|
+
label,
|
|
122
124
|
start: lineStarts[startLine - 1]!,
|
|
123
125
|
end: lineStarts[endLine]!,
|
|
124
126
|
replacement: "",
|
|
@@ -128,7 +130,7 @@ function resToSpan(
|
|
|
128
130
|
return {
|
|
129
131
|
kind: "replace",
|
|
130
132
|
index,
|
|
131
|
-
label
|
|
133
|
+
label,
|
|
132
134
|
start: Math.max(0, lineStarts[startLine - 1]! - 1),
|
|
133
135
|
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
134
136
|
replacement: "",
|
package/src/prompts.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
|
|
3
3
|
export function loadP(relativePath: string, replacements?: Record<string, string>): string {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
5
|
+
if (replacements) {
|
|
6
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
7
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return content;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function loadGuide(relativePath: string): string[] {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
export function loadGuide(relativePath: string, replacements?: Record<string, string>): string[] {
|
|
14
|
+
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
|
15
|
+
if (replacements) {
|
|
16
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
17
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return content
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => line.startsWith("- "))
|
|
24
|
+
.map((line) => line.slice(2));
|
|
19
25
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
} 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
|
+
import {
|
|
10
|
+
genDiff,
|
|
11
|
+
restoreEndings,
|
|
12
|
+
} from "./replace-diff";
|
|
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
|
+
lineHashes,
|
|
21
|
+
resEdits,
|
|
22
|
+
type HTEdit,
|
|
23
|
+
} from "./hashline";
|
|
24
|
+
import { toCwd } from "./path-utils";
|
|
25
|
+
import { abortIf } from "./runtime";
|
|
26
|
+
import { fileSnap } from "./snapshot";
|
|
27
|
+
import {
|
|
28
|
+
buildChanged,
|
|
29
|
+
buildNoop,
|
|
30
|
+
type RMeta,
|
|
31
|
+
type RMetrics,
|
|
32
|
+
} from "./replace-response";
|
|
33
|
+
import {
|
|
34
|
+
buildAppliedText,
|
|
35
|
+
mkMdTheme,
|
|
36
|
+
fmtCall,
|
|
37
|
+
fmtResultMd,
|
|
38
|
+
getPreviewInput,
|
|
39
|
+
getResultText,
|
|
40
|
+
isApplied,
|
|
41
|
+
type RPreview,
|
|
42
|
+
type RRState,
|
|
43
|
+
} from "./replace-render";
|
|
44
|
+
import { loadP, loadGuide } from "./prompts";
|
|
45
|
+
import { execPipeline, compPreview, type ReplaceDetails } from "./replace";
|
|
46
|
+
import { readAutoReadSync } from "./config";
|
|
47
|
+
|
|
48
|
+
const contentLinesSchema = Type.Array(Type.String(), {
|
|
49
|
+
description:
|
|
50
|
+
"literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const hashRangeInclSchema = Type.Array(
|
|
54
|
+
Type.String({ description: "anchor (3-char HASH)" }),
|
|
55
|
+
{
|
|
56
|
+
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.",
|
|
57
|
+
minItems: 2,
|
|
58
|
+
maxItems: 2,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Flat-mode schema: hash_range_inclusive and content_lines are at the top
|
|
64
|
+
* level instead of inside a "changes" array. Only a single edit is supported
|
|
65
|
+
* per call (no bulk changes).
|
|
66
|
+
*/
|
|
67
|
+
export const flatEditToolSchema = Type.Object(
|
|
68
|
+
{
|
|
69
|
+
path: Type.String({ description: "path" }),
|
|
70
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
71
|
+
content_lines: contentLinesSchema,
|
|
72
|
+
},
|
|
73
|
+
{ additionalProperties: false },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
type ToolDef = ToolDefinition<
|
|
77
|
+
typeof flatEditToolSchema,
|
|
78
|
+
ReplaceDetails,
|
|
79
|
+
RRState
|
|
80
|
+
> & { renderShell?: "default" | "self" };
|
|
81
|
+
|
|
82
|
+
function reuseText(context: any, content: string): Text {
|
|
83
|
+
const t = context.lastComponent instanceof Text
|
|
84
|
+
? context.lastComponent
|
|
85
|
+
: new Text("", 0, 0);
|
|
86
|
+
t.setText(content);
|
|
87
|
+
return t;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
91
|
+
const m = context.lastComponent instanceof Markdown
|
|
92
|
+
? context.lastComponent
|
|
93
|
+
: new Markdown("", 0, 0, mkMdTheme(theme));
|
|
94
|
+
m.setText(content);
|
|
95
|
+
return m;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildToolDef(): ToolDef {
|
|
99
|
+
const autoRead = readAutoReadSync();
|
|
100
|
+
const readGuidance = autoRead
|
|
101
|
+
? "Anchors are provided automatically after write operations when auto-read is enabled."
|
|
102
|
+
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
103
|
+
|
|
104
|
+
const E_DESC = loadP("../prompts/replace-flat.md", {
|
|
105
|
+
AUTO_READ_GUIDANCE: readGuidance,
|
|
106
|
+
});
|
|
107
|
+
const E_SNIPPET = loadP("../prompts/replace-flat-snippet.md");
|
|
108
|
+
const E_GUIDE = loadGuide("../prompts/replace-flat-guidelines.md", {
|
|
109
|
+
AUTO_READ_GUIDANCE: readGuidance,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: "replace",
|
|
114
|
+
label: "Replace",
|
|
115
|
+
description: E_DESC,
|
|
116
|
+
parameters: flatEditToolSchema,
|
|
117
|
+
promptSnippet: E_SNIPPET,
|
|
118
|
+
promptGuidelines: E_GUIDE,
|
|
119
|
+
prepareArguments: (args: unknown) => {
|
|
120
|
+
// Minimal normalization: file_path → path, JSON string parsing.
|
|
121
|
+
// The flat-to-canonical conversion happens in execute().
|
|
122
|
+
if (!isRec(args)) return args as any;
|
|
123
|
+
const record = { ...args };
|
|
124
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
125
|
+
record.path = record.file_path;
|
|
126
|
+
delete record.file_path;
|
|
127
|
+
}
|
|
128
|
+
if (typeof record.hash_range_inclusive === "string") {
|
|
129
|
+
try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
|
|
130
|
+
}
|
|
131
|
+
if (typeof record.content_lines === "string") {
|
|
132
|
+
try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
|
|
133
|
+
}
|
|
134
|
+
return record as any;
|
|
135
|
+
},
|
|
136
|
+
renderShell: "default",
|
|
137
|
+
renderCall(args, theme, context) {
|
|
138
|
+
const previewInput = getPreviewInput(args);
|
|
139
|
+
if (context.executionStarted) {
|
|
140
|
+
context.state.argsKey = undefined;
|
|
141
|
+
context.state.preview = undefined;
|
|
142
|
+
context.state.previewGeneration =
|
|
143
|
+
(context.state.previewGeneration ?? 0) + 1;
|
|
144
|
+
} else if (!context.argsComplete || !previewInput) {
|
|
145
|
+
context.state.argsKey = undefined;
|
|
146
|
+
context.state.preview = undefined;
|
|
147
|
+
context.state.previewGeneration =
|
|
148
|
+
(context.state.previewGeneration ?? 0) + 1;
|
|
149
|
+
} else {
|
|
150
|
+
const argsKey = JSON.stringify(previewInput);
|
|
151
|
+
if (context.state.argsKey !== argsKey) {
|
|
152
|
+
context.state.argsKey = argsKey;
|
|
153
|
+
context.state.preview = undefined;
|
|
154
|
+
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
155
|
+
context.state.previewGeneration = previewGeneration;
|
|
156
|
+
compPreview(previewInput, context.cwd)
|
|
157
|
+
.then((preview) => {
|
|
158
|
+
if (
|
|
159
|
+
context.state.argsKey === argsKey &&
|
|
160
|
+
context.state.previewGeneration === previewGeneration
|
|
161
|
+
) {
|
|
162
|
+
context.state.preview = preview;
|
|
163
|
+
context.invalidate();
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch((err: unknown) => {
|
|
167
|
+
if (
|
|
168
|
+
context.state.argsKey === argsKey &&
|
|
169
|
+
context.state.previewGeneration === previewGeneration
|
|
170
|
+
) {
|
|
171
|
+
context.state.preview = {
|
|
172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
173
|
+
};
|
|
174
|
+
context.invalidate();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const text =
|
|
180
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
181
|
+
text.setText(
|
|
182
|
+
fmtCall(
|
|
183
|
+
getPreviewInput(args) ?? undefined,
|
|
184
|
+
context.state as RRState,
|
|
185
|
+
context.expanded,
|
|
186
|
+
theme,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
return text;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
193
|
+
if (isPartial) {
|
|
194
|
+
return reuseText(context, theme.fg("warning", "Editing..."));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const typedResult = result as {
|
|
198
|
+
content?: Array<{ type: string; text?: string }>;
|
|
199
|
+
details?: ReplaceDetails;
|
|
200
|
+
};
|
|
201
|
+
const renderedText = getResultText(typedResult);
|
|
202
|
+
|
|
203
|
+
const renderState = context.state as RRState | undefined;
|
|
204
|
+
if (renderState) {
|
|
205
|
+
renderState.preview = undefined;
|
|
206
|
+
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (context.isError) {
|
|
210
|
+
return renderedText
|
|
211
|
+
? reuseText(context, `\n${theme.fg("error", renderedText)}`)
|
|
212
|
+
: new Text("", 0, 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isApplied(typedResult.details)) {
|
|
216
|
+
const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
|
|
217
|
+
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!renderedText) return new Text("", 0, 0);
|
|
221
|
+
return reuseMarkdown(context, fmtResultMd(renderedText), theme);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
225
|
+
// Wrap flat params into canonical shape for the pipeline
|
|
226
|
+
const canonical = normReq({
|
|
227
|
+
path: params.path,
|
|
228
|
+
changes: [{
|
|
229
|
+
hash_range_inclusive: params.hash_range_inclusive,
|
|
230
|
+
content_lines: params.content_lines,
|
|
231
|
+
}],
|
|
232
|
+
});
|
|
233
|
+
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
234
|
+
const path = normalizedParams.path;
|
|
235
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
236
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
237
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
238
|
+
abortIf(signal);
|
|
239
|
+
|
|
240
|
+
const {
|
|
241
|
+
originalNormalized,
|
|
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;
|
|
287
|
+
|
|
288
|
+
const editMeta: RMeta = {
|
|
289
|
+
editsAttempted,
|
|
290
|
+
noopEditsCount: noopEdits?.length ?? 0,
|
|
291
|
+
firstChangedLine,
|
|
292
|
+
lastChangedLine,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const successInput = {
|
|
296
|
+
path,
|
|
297
|
+
originalNormalized,
|
|
298
|
+
result,
|
|
299
|
+
resultHashes,
|
|
300
|
+
warnings,
|
|
301
|
+
snapshotId: updatedSnapshotId,
|
|
302
|
+
editMeta,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return buildChanged(successInput);
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function regReplaceFlat(pi: ExtensionAPI): void {
|
|
312
|
+
pi.registerTool(buildToolDef());
|
|
313
|
+
}
|
package/src/replace-normalize.ts
CHANGED
|
@@ -27,6 +27,32 @@ function coerceEditArray(items: unknown[]): unknown[] {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
|
|
32
|
+
* single objects → wrapped in array. Shared by the `changes` and `edits`
|
|
33
|
+
* normalization branches.
|
|
34
|
+
*/
|
|
35
|
+
function normalizeField(
|
|
36
|
+
record: Record<string, unknown>,
|
|
37
|
+
from: string,
|
|
38
|
+
to: string,
|
|
39
|
+
): void {
|
|
40
|
+
if (!has(record, from)) return;
|
|
41
|
+
const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
|
|
42
|
+
if (Array.isArray(raw)) {
|
|
43
|
+
record[to] = coerceEditArray(raw);
|
|
44
|
+
} else {
|
|
45
|
+
const single =
|
|
46
|
+
typeof raw === "string"
|
|
47
|
+
? tryParseJSON(raw, isRec)
|
|
48
|
+
: isRec(raw)
|
|
49
|
+
? raw
|
|
50
|
+
: undefined;
|
|
51
|
+
if (single) record[to] = coerceEditArray([single]);
|
|
52
|
+
}
|
|
53
|
+
if (from !== to) delete record[from];
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export function normReq(input: unknown): unknown {
|
|
31
57
|
if (!isRec(input)) {
|
|
32
58
|
return input;
|
|
@@ -39,32 +65,26 @@ export function normReq(input: unknown): unknown {
|
|
|
39
65
|
delete record.file_path;
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
normalizeField(record, "changes", "changes");
|
|
69
|
+
normalizeField(record, "edits", "changes");
|
|
44
70
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} else {
|
|
61
|
-
const single = typeof raw === "string"
|
|
62
|
-
? tryParseJSON(raw, isRec)
|
|
63
|
-
: isRec(raw) ? raw : undefined;
|
|
64
|
-
if (single) record.changes = coerceEditArray([single]);
|
|
71
|
+
// Handle flat format: hash_range_inclusive and content_lines at top level
|
|
72
|
+
// (no changes array). Wrap them into a single-element changes array.
|
|
73
|
+
if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
|
|
74
|
+
const hri = tryParseJSON(record.hash_range_inclusive, (v): v is string[] =>
|
|
75
|
+
Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
|
|
76
|
+
) ?? record.hash_range_inclusive;
|
|
77
|
+
|
|
78
|
+
const cl = tryParseJSON(record.content_lines, (v): v is string[] =>
|
|
79
|
+
Array.isArray(v) && v.every((i) => typeof i === "string")
|
|
80
|
+
) ?? record.content_lines;
|
|
81
|
+
|
|
82
|
+
if (Array.isArray(hri) && Array.isArray(cl)) {
|
|
83
|
+
record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
|
|
84
|
+
delete record.hash_range_inclusive;
|
|
85
|
+
delete record.content_lines;
|
|
65
86
|
}
|
|
66
|
-
|
|
67
|
-
}
|
|
87
|
+
}
|
|
68
88
|
|
|
69
89
|
return record;
|
|
70
90
|
}
|
package/src/replace-response.ts
CHANGED
|
@@ -142,7 +142,7 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
export function buildChanged(input: SuccessInput): TResult {
|
|
145
|
-
const { result, warnings, snapshotId, originalNormalized, editMeta } = input;
|
|
145
|
+
const { path, result, warnings, snapshotId, originalNormalized, editMeta } = input;
|
|
146
146
|
|
|
147
147
|
const resultLines = visLines(result);
|
|
148
148
|
const resultHashes = input.resultHashes ?? lineHashes(result);
|
|
@@ -150,9 +150,12 @@ export function buildChanged(input: SuccessInput): TResult {
|
|
|
150
150
|
const addedLines = cntDiff(diffResult.diff, "+");
|
|
151
151
|
const removedLines = cntDiff(diffResult.diff, "-");
|
|
152
152
|
const warningsBlock = warnBlock(warnings);
|
|
153
|
+
const successPrefix = `Successfully replaced in ${path}.`;
|
|
153
154
|
const text = resultLines.length === 0
|
|
154
155
|
? "File is empty. Use replace to insert content."
|
|
155
|
-
: warningsBlock
|
|
156
|
+
: warningsBlock
|
|
157
|
+
? `${successPrefix}${warningsBlock}`
|
|
158
|
+
: successPrefix;
|
|
156
159
|
|
|
157
160
|
const metrics = buildM({
|
|
158
161
|
classification: "applied",
|