pi-hashline-edit-pro 0.2.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/LICENSE +21 -0
- package/README.md +143 -0
- package/index.ts +64 -0
- package/package.json +52 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +58 -0
- package/prompts/read-guidelines.md +3 -0
- package/prompts/read-snippet.md +1 -0
- package/prompts/read.md +28 -0
- package/src/edit-diff.ts +234 -0
- package/src/edit-normalize.ts +68 -0
- package/src/edit-render.ts +280 -0
- package/src/edit-response.ts +531 -0
- package/src/edit.ts +689 -0
- package/src/file-kind.ts +161 -0
- package/src/fs-write.ts +105 -0
- package/src/hashline/apply.ts +660 -0
- package/src/hashline/hash.ts +192 -0
- package/src/hashline/index.ts +70 -0
- package/src/hashline/parse.ts +116 -0
- package/src/hashline/resolve.ts +552 -0
- package/src/path-utils.ts +13 -0
- package/src/read.ts +256 -0
- package/src/runtime.ts +3 -0
- package/src/snapshot.ts +29 -0
- package/src/utils.ts +11 -0
package/src/edit.ts
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
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 "@sinclair/typebox";
|
|
8
|
+
import { constants } from "fs";
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { access as fsAccess } from "fs/promises";
|
|
11
|
+
import {
|
|
12
|
+
detectLineEnding,
|
|
13
|
+
generateDiffString,
|
|
14
|
+
normalizeToLF,
|
|
15
|
+
restoreLineEndings,
|
|
16
|
+
stripBom,
|
|
17
|
+
} from "./edit-diff";
|
|
18
|
+
import { normalizeEditRequest } from "./edit-normalize";
|
|
19
|
+
import { isRecord, hasOwn } from "./utils";
|
|
20
|
+
import { resolveMutationTargetPath, writeFileAtomically } from "./fs-write";
|
|
21
|
+
import {
|
|
22
|
+
applyHashlineEdits,
|
|
23
|
+
computeLineHashes,
|
|
24
|
+
resolveEditAnchors,
|
|
25
|
+
type HashlineToolEdit,
|
|
26
|
+
} from "./hashline";
|
|
27
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
28
|
+
import { resolveToCwd } from "./path-utils";
|
|
29
|
+
import { throwIfAborted } from "./runtime";
|
|
30
|
+
import { getFileSnapshot } from "./snapshot";
|
|
31
|
+
import {
|
|
32
|
+
buildChangedResponse,
|
|
33
|
+
buildFullResponse,
|
|
34
|
+
buildNoopResponse,
|
|
35
|
+
buildRangesResponse,
|
|
36
|
+
type EditMeta,
|
|
37
|
+
type ReturnMode,
|
|
38
|
+
} from "./edit-response";
|
|
39
|
+
import {
|
|
40
|
+
buildAppliedChangedResultText,
|
|
41
|
+
createRenderedEditMarkdownTheme,
|
|
42
|
+
formatEditCall,
|
|
43
|
+
formatRenderedEditResultMarkdown,
|
|
44
|
+
getRenderablePreviewInput,
|
|
45
|
+
getRenderedEditTextContent,
|
|
46
|
+
isAppliedChangedResult,
|
|
47
|
+
type EditPreview,
|
|
48
|
+
type EditRenderState,
|
|
49
|
+
} from "./edit-render";
|
|
50
|
+
|
|
51
|
+
function stringEnumSchema<const Values extends readonly string[]>(
|
|
52
|
+
values: Values,
|
|
53
|
+
options: { description: string },
|
|
54
|
+
) {
|
|
55
|
+
return Type.Unsafe<Values[number]>({
|
|
56
|
+
type: "string",
|
|
57
|
+
enum: [...values],
|
|
58
|
+
description: options.description,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hashlineEditLinesSchema = Type.Array(Type.String(), {
|
|
63
|
+
description:
|
|
64
|
+
"replacement content, one array entry per line, no HASH: prefix",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const returnRangeSchema = Type.Object(
|
|
68
|
+
{
|
|
69
|
+
start: Type.Integer({
|
|
70
|
+
minimum: 1,
|
|
71
|
+
description: "first post-edit line to return",
|
|
72
|
+
}),
|
|
73
|
+
end: Type.Optional(
|
|
74
|
+
Type.Integer({
|
|
75
|
+
minimum: 1,
|
|
76
|
+
description: "last post-edit line to return",
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
{ additionalProperties: false },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const hashlineEditItemSchema = Type.Object(
|
|
84
|
+
{
|
|
85
|
+
op: stringEnumSchema(
|
|
86
|
+
["replace", "append", "prepend"] as const,
|
|
87
|
+
{
|
|
88
|
+
description:
|
|
89
|
+
'edit operation. "replace" requires "start" + "end" (inclusive range); "append"/"prepend" take an optional "pos" anchor. Every edit must set op. The legacy "replace_text" op is no longer supported; use a hash-anchored "replace" instead.',
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
start: Type.Optional(
|
|
93
|
+
Type.String({
|
|
94
|
+
description:
|
|
95
|
+
"required range-start anchor for op \"replace\" (bare 4-character HASH copied from read output); no content may follow the hash",
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
end: Type.Optional(
|
|
99
|
+
Type.String({
|
|
100
|
+
description:
|
|
101
|
+
"required range-end anchor for op \"replace\" (bare 4-character HASH). To replace a single line, set start = end = the line's hash",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
pos: Type.Optional(
|
|
105
|
+
Type.String({
|
|
106
|
+
description:
|
|
107
|
+
"anchor for op \"append\" or \"prepend\" (bare 4-character HASH). Omit for file-boundary insertion (EOF/BOF).",
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
lines: Type.Optional(hashlineEditLinesSchema),
|
|
111
|
+
},
|
|
112
|
+
{ additionalProperties: false },
|
|
113
|
+
);
|
|
114
|
+
export const hashlineEditToolSchema = Type.Object(
|
|
115
|
+
{
|
|
116
|
+
path: Type.String({ description: "path" }),
|
|
117
|
+
returnMode: Type.Optional(
|
|
118
|
+
stringEnumSchema(["changed", "full", "ranges"] as const, {
|
|
119
|
+
description: 'response mode: "changed", "full", or "ranges"',
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
returnRanges: Type.Optional(
|
|
123
|
+
Type.Array(returnRangeSchema, {
|
|
124
|
+
description: "post-edit line ranges when returnMode is ranges",
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
edits: Type.Optional(
|
|
128
|
+
Type.Array(hashlineEditItemSchema, { description: "edits over $path" }),
|
|
129
|
+
),
|
|
130
|
+
// File-path alias and JSON-stringified edits are still absorbed by
|
|
131
|
+
// normalizeEditRequest in the prepareArguments hook, which runs before
|
|
132
|
+
// this schema is validated. The legacy native top-level oldText/newText
|
|
133
|
+
// dialect is NOT folded — it is rejected outright with [E_LEGACY_SHAPE]
|
|
134
|
+
// in assertEditRequest, so it never reaches the schema validator.
|
|
135
|
+
},
|
|
136
|
+
{ additionalProperties: false },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
type ReturnRange = {
|
|
140
|
+
start: number;
|
|
141
|
+
end?: number;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type ReturnedRangePreview = {
|
|
145
|
+
start: number;
|
|
146
|
+
end: number;
|
|
147
|
+
text: string;
|
|
148
|
+
nextOffset?: number;
|
|
149
|
+
empty?: true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
type FullContentPreview = {
|
|
153
|
+
text: string;
|
|
154
|
+
nextOffset?: number;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type EditRequestParams = {
|
|
158
|
+
path: string;
|
|
159
|
+
returnMode?: "changed" | "full" | "ranges";
|
|
160
|
+
returnRanges?: ReturnRange[];
|
|
161
|
+
edits?: HashlineToolEdit[];
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
type EditMetrics = {
|
|
165
|
+
edits_attempted: number;
|
|
166
|
+
edits_noop: number;
|
|
167
|
+
warnings: number;
|
|
168
|
+
return_mode: "changed" | "full" | "ranges";
|
|
169
|
+
classification: "applied" | "noop";
|
|
170
|
+
changed_lines?: { first: number; last: number };
|
|
171
|
+
added_lines?: number;
|
|
172
|
+
removed_lines?: number;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export type HashlineEditToolDetails = {
|
|
176
|
+
diff: string;
|
|
177
|
+
firstChangedLine?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Post-edit snapshot fingerprint. Surfaced in details only — the LLM no
|
|
180
|
+
* longer receives or echoes it. Hosts may use this for UI hints (e.g.
|
|
181
|
+
* "file changed since last view"). See plan W2.
|
|
182
|
+
*/
|
|
183
|
+
snapshotId?: string;
|
|
184
|
+
classification?: "noop";
|
|
185
|
+
nextOffset?: number;
|
|
186
|
+
fullContent?: FullContentPreview;
|
|
187
|
+
returnedRanges?: ReturnedRangePreview[];
|
|
188
|
+
structureOutline?: string[];
|
|
189
|
+
/**
|
|
190
|
+
* Phase 2 C — opt-in observability surface for hosts. Never echoed in text.
|
|
191
|
+
* Hosts can use it for adoption/regression dashboards.
|
|
192
|
+
*/
|
|
193
|
+
metrics?: EditMetrics;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const EDIT_DESC = readFileSync(
|
|
197
|
+
new URL("../prompts/edit.md", import.meta.url),
|
|
198
|
+
"utf-8",
|
|
199
|
+
).trim();
|
|
200
|
+
|
|
201
|
+
const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
202
|
+
new URL("../prompts/edit-snippet.md", import.meta.url),
|
|
203
|
+
"utf-8",
|
|
204
|
+
).trim();
|
|
205
|
+
|
|
206
|
+
const ROOT_KEYS = new Set(["path", "returnMode", "returnRanges", "edits"]);
|
|
207
|
+
|
|
208
|
+
// Validates the canonical edit request envelope after normalizeEditRequest has
|
|
209
|
+
// converged any model dialects. Per-edit structural validation is delegated to
|
|
210
|
+
// resolveEditAnchors (src/hashline.ts), which is the single source of truth for
|
|
211
|
+
// edit-item shape + op constraints. This function validates only the root-level
|
|
212
|
+
// request fields: path, returnMode, returnRanges, and that edits is an array.
|
|
213
|
+
//
|
|
214
|
+
// Intentional overlap with the published TypeBox schema: pi normally runs AJV
|
|
215
|
+
// validation before execute(), but that can be disabled in environments without
|
|
216
|
+
// runtime code generation support, so the semantic checks here are the backstop.
|
|
217
|
+
export function assertEditRequest(
|
|
218
|
+
request: unknown,
|
|
219
|
+
): asserts request is EditRequestParams {
|
|
220
|
+
if (!isRecord(request)) {
|
|
221
|
+
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The legacy native top-level oldText/newText dialect (with or without the
|
|
225
|
+
// snake_case aliases) is no longer supported. Hash-anchored edits are the
|
|
226
|
+
// only path — the legacy shape is what produces
|
|
227
|
+
// `[E_NO_MATCH] replace_text found no exact unique match` on real-world
|
|
228
|
+
// whitespace/Unicode drift. Reject early with a clear error so the model
|
|
229
|
+
// learns the right shape on the next turn.
|
|
230
|
+
for (const legacyKey of ["oldText", "newText", "old_text", "new_text"]) {
|
|
231
|
+
if (hasOwn(request, legacyKey)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {op:"replace", start:"<HASH>", end:"<HASH", lines:[...]}.`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const unknownRootKeys = Object.keys(request).filter(
|
|
239
|
+
(key) => !ROOT_KEYS.has(key),
|
|
240
|
+
);
|
|
241
|
+
if (unknownRootKeys.length > 0) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: ${unknownRootKeys.join(", ")}.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
248
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (hasOwn(request, "edits") && !Array.isArray(request.edits)) {
|
|
252
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided.');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (hasOwn(request, "returnMode")) {
|
|
256
|
+
if (
|
|
257
|
+
request.returnMode !== "changed" &&
|
|
258
|
+
request.returnMode !== "full" &&
|
|
259
|
+
request.returnMode !== "ranges"
|
|
260
|
+
) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'[E_BAD_SHAPE] Edit request field "returnMode" must be "changed", "full", or "ranges" when provided.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (hasOwn(request, "returnRanges")) {
|
|
268
|
+
if (
|
|
269
|
+
!Array.isArray(request.returnRanges) ||
|
|
270
|
+
request.returnRanges.length === 0
|
|
271
|
+
) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
'[E_BAD_SHAPE] Edit request field "returnRanges" must be a non-empty array when provided.',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
for (const [index, range] of request.returnRanges.entries()) {
|
|
277
|
+
if (!isRecord(range)) {
|
|
278
|
+
throw new Error(`[E_BAD_SHAPE] returnRanges[${index}] must be an object.`);
|
|
279
|
+
}
|
|
280
|
+
if (!Number.isInteger(range.start) || (range.start as number) < 1) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`[E_BAD_SHAPE] returnRanges[${index}].start must be a positive integer.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (hasOwn(range, "end")) {
|
|
286
|
+
if (!Number.isInteger(range.end) || (range.end as number) < 1) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`[E_BAD_SHAPE] returnRanges[${index}].end must be a positive integer when provided.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if ((range.end as number) < (range.start as number)) {
|
|
292
|
+
throw new Error(`[E_BAD_SHAPE] returnRanges[${index}].end must be >= start.`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (request.returnMode === "ranges") {
|
|
299
|
+
if (
|
|
300
|
+
!Array.isArray(request.returnRanges) ||
|
|
301
|
+
request.returnRanges.length === 0
|
|
302
|
+
) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
'[E_BAD_SHAPE] Edit request with returnMode "ranges" requires a non-empty "returnRanges" array.',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} else if (hasOwn(request, "returnRanges")) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
'[E_BAD_SHAPE] Edit request field "returnRanges" is only supported when returnMode is "ranges".',
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Per-edit validation lives in resolveEditAnchors — the single source of
|
|
314
|
+
// truth for edit-item shape, op constraints, and anchor parsing.
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Shared edit pipeline: normalize, validate, read file, resolve anchors,
|
|
319
|
+
* and apply edits. Both `computeEditPreview` (dry-run) and `execute()`
|
|
320
|
+
* (real) call this; the access mode parameter controls whether the file
|
|
321
|
+
* must be writable.
|
|
322
|
+
*/
|
|
323
|
+
async function executeEditPipeline(
|
|
324
|
+
request: unknown,
|
|
325
|
+
cwd: string,
|
|
326
|
+
accessMode: number,
|
|
327
|
+
signal?: AbortSignal,
|
|
328
|
+
): Promise<{
|
|
329
|
+
path: string;
|
|
330
|
+
toolEdits: HashlineToolEdit[];
|
|
331
|
+
originalNormalized: string;
|
|
332
|
+
result: string;
|
|
333
|
+
bom: string;
|
|
334
|
+
originalEnding: "\r\n" | "\n";
|
|
335
|
+
hadUtf8DecodeErrors: boolean;
|
|
336
|
+
warnings: string[];
|
|
337
|
+
noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
|
|
338
|
+
firstChangedLine?: number;
|
|
339
|
+
lastChangedLine?: number;
|
|
340
|
+
originalHashes?: string[];
|
|
341
|
+
}> {
|
|
342
|
+
const normalized = normalizeEditRequest(request);
|
|
343
|
+
assertEditRequest(normalized);
|
|
344
|
+
|
|
345
|
+
const params = normalized;
|
|
346
|
+
const path = params.path;
|
|
347
|
+
const absolutePath = resolveToCwd(path, cwd);
|
|
348
|
+
const toolEdits = Array.isArray(params.edits)
|
|
349
|
+
? (params.edits as HashlineToolEdit[])
|
|
350
|
+
: [];
|
|
351
|
+
|
|
352
|
+
if (toolEdits.length === 0) {
|
|
353
|
+
throw new Error("No edits provided.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
throwIfAborted(signal);
|
|
357
|
+
try {
|
|
358
|
+
await fsAccess(absolutePath, accessMode);
|
|
359
|
+
} catch (error: unknown) {
|
|
360
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
361
|
+
if (code === "ENOENT") {
|
|
362
|
+
throw new Error(`File not found: ${path}`);
|
|
363
|
+
}
|
|
364
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
365
|
+
const accessLabel =
|
|
366
|
+
accessMode & constants.W_OK ? "not writable" : "not readable";
|
|
367
|
+
throw new Error(`File is ${accessLabel}: ${path}`);
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`Cannot access file: ${path}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
throwIfAborted(signal);
|
|
373
|
+
const file = await loadFileKindAndText(absolutePath);
|
|
374
|
+
if (file.kind === "directory") {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Path is a directory: ${path}. Use ls to inspect directories.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (file.kind === "image") {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (file.kind === "binary") {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throwIfAborted(signal);
|
|
391
|
+
const { bom, text: rawContent } = stripBom(file.text);
|
|
392
|
+
const originalEnding = detectLineEnding(rawContent);
|
|
393
|
+
const originalNormalized = normalizeToLF(rawContent);
|
|
394
|
+
|
|
395
|
+
// Pre-compute hashes for the original file once. The same array is passed
|
|
396
|
+
// into applyHashlineEdits so validation and the stale-anchor retry block
|
|
397
|
+
// agree on what each line's hash is, and so we can return updated
|
|
398
|
+
// occurrence-aware anchors in the response without recomputing.
|
|
399
|
+
const originalHashes = computeLineHashes(originalNormalized);
|
|
400
|
+
|
|
401
|
+
const resolved = resolveEditAnchors(toolEdits);
|
|
402
|
+
const anchorResult = applyHashlineEdits(
|
|
403
|
+
originalNormalized,
|
|
404
|
+
resolved,
|
|
405
|
+
signal,
|
|
406
|
+
originalHashes,
|
|
407
|
+
absolutePath,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
path,
|
|
412
|
+
toolEdits,
|
|
413
|
+
originalNormalized,
|
|
414
|
+
result: anchorResult.content,
|
|
415
|
+
bom,
|
|
416
|
+
originalEnding,
|
|
417
|
+
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
418
|
+
warnings: [...(anchorResult.warnings ?? [])],
|
|
419
|
+
noopEdits: anchorResult.noopEdits,
|
|
420
|
+
firstChangedLine: anchorResult.firstChangedLine,
|
|
421
|
+
lastChangedLine: anchorResult.lastChangedLine,
|
|
422
|
+
originalHashes,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function computeEditPreview(
|
|
427
|
+
request: unknown,
|
|
428
|
+
cwd: string,
|
|
429
|
+
): Promise<EditPreview> {
|
|
430
|
+
try {
|
|
431
|
+
const { path, originalNormalized, result } = await executeEditPipeline(
|
|
432
|
+
request,
|
|
433
|
+
cwd,
|
|
434
|
+
constants.R_OK,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
if (originalNormalized === result) {
|
|
438
|
+
return {
|
|
439
|
+
error: `No changes made to ${path}. The edits produced identical content.`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { diff: generateDiffString(originalNormalized, result, 4, computeLineHashes(result)).diff };
|
|
444
|
+
} catch (error: unknown) {
|
|
445
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
type EditToolDefinition = ToolDefinition<
|
|
450
|
+
typeof hashlineEditToolSchema,
|
|
451
|
+
HashlineEditToolDetails,
|
|
452
|
+
EditRenderState
|
|
453
|
+
> & { renderShell?: "default" | "self" };
|
|
454
|
+
|
|
455
|
+
const editToolDefinition: EditToolDefinition = {
|
|
456
|
+
name: "edit",
|
|
457
|
+
label: "Edit",
|
|
458
|
+
description: EDIT_DESC,
|
|
459
|
+
parameters: hashlineEditToolSchema,
|
|
460
|
+
promptSnippet: EDIT_PROMPT_SNIPPET,
|
|
461
|
+
// Converge model dialects (JSON-string edits, file_path alias) onto the
|
|
462
|
+
// canonical hashline shape before Pi validates and before execute(). The
|
|
463
|
+
// legacy top-level oldText/newText dialect is NOT folded — it is rejected
|
|
464
|
+
// outright with [E_LEGACY_SHAPE] in assertEditRequest. See
|
|
465
|
+
// src/edit-normalize.ts.
|
|
466
|
+
prepareArguments: (args: unknown) =>
|
|
467
|
+
normalizeEditRequest(args) as EditRequestParams,
|
|
468
|
+
// Force the default tool shell (Box with pending/success/error background) so
|
|
469
|
+
// we don't inherit renderShell: "self" from the built-in edit tool of the
|
|
470
|
+
// same name, which would drop the shared background color block.
|
|
471
|
+
renderShell: "default",
|
|
472
|
+
renderCall(args, theme, context) {
|
|
473
|
+
const previewInput = getRenderablePreviewInput(args);
|
|
474
|
+
if (context.executionStarted) {
|
|
475
|
+
context.state.argsKey = undefined;
|
|
476
|
+
context.state.preview = undefined;
|
|
477
|
+
context.state.previewGeneration =
|
|
478
|
+
(context.state.previewGeneration ?? 0) + 1;
|
|
479
|
+
} else if (!context.argsComplete || !previewInput) {
|
|
480
|
+
context.state.argsKey = undefined;
|
|
481
|
+
context.state.preview = undefined;
|
|
482
|
+
context.state.previewGeneration =
|
|
483
|
+
(context.state.previewGeneration ?? 0) + 1;
|
|
484
|
+
} else {
|
|
485
|
+
const argsKey = JSON.stringify(previewInput);
|
|
486
|
+
if (context.state.argsKey !== argsKey) {
|
|
487
|
+
context.state.argsKey = argsKey;
|
|
488
|
+
context.state.preview = undefined;
|
|
489
|
+
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
490
|
+
context.state.previewGeneration = previewGeneration;
|
|
491
|
+
computeEditPreview(previewInput, context.cwd)
|
|
492
|
+
.then((preview) => {
|
|
493
|
+
if (
|
|
494
|
+
context.state.argsKey === argsKey &&
|
|
495
|
+
context.state.previewGeneration === previewGeneration
|
|
496
|
+
) {
|
|
497
|
+
context.state.preview = preview;
|
|
498
|
+
context.invalidate();
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
.catch((err: unknown) => {
|
|
502
|
+
if (
|
|
503
|
+
context.state.argsKey === argsKey &&
|
|
504
|
+
context.state.previewGeneration === previewGeneration
|
|
505
|
+
) {
|
|
506
|
+
context.state.preview = {
|
|
507
|
+
error: err instanceof Error ? err.message : String(err),
|
|
508
|
+
};
|
|
509
|
+
context.invalidate();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const text =
|
|
515
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
516
|
+
text.setText(
|
|
517
|
+
formatEditCall(
|
|
518
|
+
getRenderablePreviewInput(args) ?? undefined,
|
|
519
|
+
context.state as EditRenderState,
|
|
520
|
+
context.expanded,
|
|
521
|
+
theme,
|
|
522
|
+
),
|
|
523
|
+
);
|
|
524
|
+
return text;
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
528
|
+
if (isPartial) {
|
|
529
|
+
const text =
|
|
530
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
531
|
+
text.setText(theme.fg("warning", "Editing..."));
|
|
532
|
+
return text;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const typedResult = result as {
|
|
536
|
+
content?: Array<{ type: string; text?: string }>;
|
|
537
|
+
details?: HashlineEditToolDetails;
|
|
538
|
+
};
|
|
539
|
+
const renderedText = getRenderedEditTextContent(typedResult);
|
|
540
|
+
|
|
541
|
+
const renderState = context.state as EditRenderState | undefined;
|
|
542
|
+
const previewBeforeResult = renderState?.preview;
|
|
543
|
+
if (renderState) {
|
|
544
|
+
renderState.preview = undefined;
|
|
545
|
+
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (context.isError) {
|
|
549
|
+
if (!renderedText) {
|
|
550
|
+
return new Text("", 0, 0);
|
|
551
|
+
}
|
|
552
|
+
const text =
|
|
553
|
+
context.lastComponent instanceof Text
|
|
554
|
+
? context.lastComponent
|
|
555
|
+
: new Text("", 0, 0);
|
|
556
|
+
text.setText(`\n${theme.fg("error", renderedText)}`);
|
|
557
|
+
return text;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (isAppliedChangedResult(typedResult.details)) {
|
|
561
|
+
const appliedChangedText = buildAppliedChangedResultText(
|
|
562
|
+
renderedText,
|
|
563
|
+
typedResult.details,
|
|
564
|
+
previewBeforeResult,
|
|
565
|
+
theme,
|
|
566
|
+
);
|
|
567
|
+
if (!appliedChangedText) {
|
|
568
|
+
return new Text("", 0, 0);
|
|
569
|
+
}
|
|
570
|
+
const text =
|
|
571
|
+
context.lastComponent instanceof Text
|
|
572
|
+
? context.lastComponent
|
|
573
|
+
: new Text("", 0, 0);
|
|
574
|
+
text.setText(appliedChangedText);
|
|
575
|
+
return text;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!renderedText) {
|
|
579
|
+
return new Text("", 0, 0);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const markdown =
|
|
583
|
+
context.lastComponent instanceof Markdown
|
|
584
|
+
? context.lastComponent
|
|
585
|
+
: new Markdown("", 0, 0, createRenderedEditMarkdownTheme(theme));
|
|
586
|
+
markdown.setText(formatRenderedEditResultMarkdown(renderedText));
|
|
587
|
+
return markdown;
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
591
|
+
// normalizeEditRequest is re-applied here so execute does not depend on
|
|
592
|
+
// prepareArguments having run. Idempotent on canonical input.
|
|
593
|
+
const normalized = normalizeEditRequest(params);
|
|
594
|
+
assertEditRequest(normalized);
|
|
595
|
+
const normalizedParams = normalized;
|
|
596
|
+
const path = normalizedParams.path;
|
|
597
|
+
const absolutePath = resolveToCwd(path, ctx.cwd);
|
|
598
|
+
const returnMode = normalizedParams.returnMode ?? "changed";
|
|
599
|
+
const requestedReturnRanges = normalizedParams.returnRanges;
|
|
600
|
+
|
|
601
|
+
const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
|
|
602
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
603
|
+
throwIfAborted(signal);
|
|
604
|
+
|
|
605
|
+
const {
|
|
606
|
+
originalNormalized,
|
|
607
|
+
result,
|
|
608
|
+
bom,
|
|
609
|
+
originalEnding,
|
|
610
|
+
hadUtf8DecodeErrors,
|
|
611
|
+
warnings,
|
|
612
|
+
noopEdits,
|
|
613
|
+
firstChangedLine,
|
|
614
|
+
lastChangedLine,
|
|
615
|
+
} = await executeEditPipeline(
|
|
616
|
+
normalized,
|
|
617
|
+
ctx.cwd,
|
|
618
|
+
constants.R_OK | constants.W_OK,
|
|
619
|
+
signal,
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
const editsAttempted = Array.isArray(normalizedParams.edits)
|
|
623
|
+
? normalizedParams.edits.length
|
|
624
|
+
: 0;
|
|
625
|
+
|
|
626
|
+
if (originalNormalized === result) {
|
|
627
|
+
const noopSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
|
|
628
|
+
return buildNoopResponse({
|
|
629
|
+
path,
|
|
630
|
+
returnMode: returnMode as ReturnMode,
|
|
631
|
+
requestedReturnRanges,
|
|
632
|
+
noopEdits,
|
|
633
|
+
originalNormalized,
|
|
634
|
+
snapshotId: noopSnapshotId,
|
|
635
|
+
editMeta: {
|
|
636
|
+
editsAttempted,
|
|
637
|
+
noopEditsCount: noopEdits?.length ?? 0,
|
|
638
|
+
},
|
|
639
|
+
warnings,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (hadUtf8DecodeErrors) {
|
|
644
|
+
warnings.push(
|
|
645
|
+
"Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
throwIfAborted(signal);
|
|
650
|
+
await writeFileAtomically(
|
|
651
|
+
absolutePath,
|
|
652
|
+
bom + restoreLineEndings(result, originalEnding),
|
|
653
|
+
);
|
|
654
|
+
const updatedSnapshotId = (await getFileSnapshot(absolutePath))
|
|
655
|
+
.snapshotId;
|
|
656
|
+
|
|
657
|
+
const editMeta: EditMeta = {
|
|
658
|
+
editsAttempted,
|
|
659
|
+
noopEditsCount: noopEdits?.length ?? 0,
|
|
660
|
+
firstChangedLine,
|
|
661
|
+
lastChangedLine,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const successInput = {
|
|
665
|
+
path,
|
|
666
|
+
returnMode: returnMode as ReturnMode,
|
|
667
|
+
requestedReturnRanges,
|
|
668
|
+
originalNormalized,
|
|
669
|
+
result,
|
|
670
|
+
// Hash the post-edit file once. The result builders will use
|
|
671
|
+
// these for the per-line anchors in the full / ranges / changed
|
|
672
|
+
// response blocks; computing once here is cheaper than letting
|
|
673
|
+
// each builder recompute.
|
|
674
|
+
resultHashes: computeLineHashes(result),
|
|
675
|
+
warnings,
|
|
676
|
+
snapshotId: updatedSnapshotId,
|
|
677
|
+
editMeta,
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
if (returnMode === "full") return buildFullResponse(successInput);
|
|
681
|
+
if (returnMode === "ranges") return buildRangesResponse(successInput);
|
|
682
|
+
return buildChangedResponse(successInput);
|
|
683
|
+
});
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
export function registerEditTool(pi: ExtensionAPI): void {
|
|
688
|
+
pi.registerTool(editToolDefinition);
|
|
689
|
+
}
|