pi-readseek 0.7.6 → 0.7.8
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 +28 -46
- package/dist/index.js +7333 -0
- package/package.json +8 -7
- package/index.ts +0 -147
- package/src/binary-detect.ts +0 -22
- package/src/check.ts +0 -92
- package/src/coerce-obvious-int.ts +0 -41
- package/src/confusable-hyphens.ts +0 -6
- package/src/def.ts +0 -143
- package/src/diff-data.ts +0 -302
- package/src/edit-classify.ts +0 -58
- package/src/edit-diff.ts +0 -359
- package/src/edit-output.ts +0 -110
- package/src/edit-render-helpers.ts +0 -141
- package/src/edit-syntax-validate.ts +0 -65
- package/src/edit.ts +0 -686
- package/src/file-map.ts +0 -17
- package/src/fs-error.ts +0 -33
- package/src/grep-budget.ts +0 -35
- package/src/grep-output.ts +0 -181
- package/src/grep-render-helpers.ts +0 -77
- package/src/grep-symbol-scope.ts +0 -184
- package/src/grep.ts +0 -608
- package/src/hashline.ts +0 -736
- package/src/hover.ts +0 -140
- package/src/path-utils.ts +0 -24
- package/src/pending-diff-preview.ts +0 -269
- package/src/read-local-bundle.ts +0 -75
- package/src/read-output.ts +0 -186
- package/src/read-render-helpers.ts +0 -104
- package/src/read.ts +0 -661
- package/src/readseek/constants.ts +0 -17
- package/src/readseek/enums.ts +0 -38
- package/src/readseek/formatter.ts +0 -350
- package/src/readseek/symbol-error-format.ts +0 -18
- package/src/readseek/symbol-lookup.ts +0 -240
- package/src/readseek/symbol-tree.ts +0 -21
- package/src/readseek/types.ts +0 -49
- package/src/readseek-client.ts +0 -1250
- package/src/readseek-params.ts +0 -39
- package/src/readseek-settings.ts +0 -263
- package/src/readseek-value.ts +0 -184
- package/src/refs-output.ts +0 -47
- package/src/refs.ts +0 -161
- package/src/register-tool.ts +0 -29
- package/src/rename.ts +0 -182
- package/src/replace-symbol.ts +0 -77
- package/src/runtime.ts +0 -3
- package/src/search-output.ts +0 -50
- package/src/search.ts +0 -214
- package/src/session-anchors.ts +0 -34
- package/src/stat-search-path.ts +0 -29
- package/src/syntax-validate-mode.ts +0 -15
- package/src/tool-prompt-metadata.ts +0 -107
- package/src/tool-types.ts +0 -12
- package/src/tui-diff-component.ts +0 -108
- package/src/tui-diff-renderer.ts +0 -92
- package/src/tui-render-utils.ts +0 -286
- package/src/view.ts +0 -158
- package/src/write.ts +0 -357
package/src/edit.ts
DELETED
|
@@ -1,686 +0,0 @@
|
|
|
1
|
-
import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
|
|
2
|
-
|
|
3
|
-
import { createPatch } from "diff";
|
|
4
|
-
import { withFileMutationQueue, type ExtensionAPI, type EditToolDetails, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Type } from "@sinclair/typebox";
|
|
6
|
-
import type { Static } from "@sinclair/typebox";
|
|
7
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
-
|
|
9
|
-
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
10
|
-
import { detectLineEnding, generateCompactOrFullDiff, normalizeToLF, replaceText, restoreLineEndings, stripBom } from "./edit-diff.js";
|
|
11
|
-
import { HashlineMismatchError, applyHashlineEdits, computeLineHash, ensureHashInit, parseLineRef, type HashlineEditItem, escapeControlCharsForDisplay } from "./hashline.js";
|
|
12
|
-
import { resolveToCwd } from "./path-utils.js";
|
|
13
|
-
import { looksLikeBinary } from "./binary-detect.js";
|
|
14
|
-
import { throwIfAborted } from "./runtime.js";
|
|
15
|
-
import { formatFsError } from "./fs-error.js";
|
|
16
|
-
import { buildEditOutput } from "./edit-output.js";
|
|
17
|
-
import { classifyEdit } from "./edit-classify.js";
|
|
18
|
-
import type { SemanticSummary } from "./readseek-value.js";
|
|
19
|
-
import { buildReadSeekError } from "./readseek-value.js";
|
|
20
|
-
import { classifyReadSeekFailure } from "./readseek-client.js";
|
|
21
|
-
import { countEditTypes, formatEditCallText, formatEditResultText } from "./edit-render-helpers.js";
|
|
22
|
-
import { validateSyntaxRegression } from "./edit-syntax-validate.js";
|
|
23
|
-
import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-validate-mode.js";
|
|
24
|
-
import { replaceSymbol } from "./replace-symbol.js";
|
|
25
|
-
import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
|
|
26
|
-
import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
|
|
27
|
-
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
28
|
-
import { upsertDiffComponent, upsertTextComponent } from "./tui-diff-component.js";
|
|
29
|
-
import type { FileMutatedCallback, FreshAnchorsPredicate } from "./tool-types.js";
|
|
30
|
-
import { filePathParam, registerReadSeekTool } from "./register-tool.js";
|
|
31
|
-
|
|
32
|
-
const EDIT_PENDING_PREVIEW_STATE_KEY = "hashline-edit-pending-preview";
|
|
33
|
-
|
|
34
|
-
function pendingPreviewLines(summary: string, preview: PendingDiffPreviewResult | undefined, expanded: boolean): { lines: string[]; diffData?: ReturnType<typeof buildDiffData>; headerLabel?: string } {
|
|
35
|
-
if (!preview || preview.type !== "ok") return { lines: summary.split("\n") };
|
|
36
|
-
const diffData = buildDiffData({ path: preview.data.filePath, oldContent: preview.data.previousContent, newContent: preview.data.nextContent, diff: preview.data.diff });
|
|
37
|
-
const headerLine = summaryLine(preview.data.headerLabel, { hidden: !expanded });
|
|
38
|
-
return { lines: [summary, headerLine], diffData: expanded ? diffData : undefined, headerLabel: preview.data.headerLabel };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const hashlineEditItemSchema = Type.Union([
|
|
43
|
-
Type.Object({ set_line: Type.Object({ anchor: Type.String(), new_text: Type.String() }) }, { additionalProperties: false }),
|
|
44
|
-
Type.Object(
|
|
45
|
-
{ replace_lines: Type.Object({ start_anchor: Type.String(), end_anchor: Type.String(), new_text: Type.String() }) },
|
|
46
|
-
{ additionalProperties: false },
|
|
47
|
-
),
|
|
48
|
-
Type.Object({ insert_after: Type.Object({ anchor: Type.String(), new_text: Type.String() }) }, { additionalProperties: false }),
|
|
49
|
-
Type.Object(
|
|
50
|
-
{ replace: Type.Object({ old_text: Type.String(), new_text: Type.String(), all: Type.Optional(Type.Boolean()), fuzzy: Type.Optional(Type.Boolean()) }) },
|
|
51
|
-
{ additionalProperties: false },
|
|
52
|
-
),
|
|
53
|
-
Type.Object(
|
|
54
|
-
{ replace_symbol: Type.Object({ symbol: Type.String(), new_body: Type.String() }) },
|
|
55
|
-
{ additionalProperties: false },
|
|
56
|
-
),
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
const hashlineEditSchema = Type.Object(
|
|
60
|
-
{
|
|
61
|
-
path: filePathParam(),
|
|
62
|
-
edits: Type.Optional(Type.Array(hashlineEditItemSchema, {
|
|
63
|
-
description: "Edits: set_line, replace_lines, insert_after, replace_symbol, or replace",
|
|
64
|
-
})),
|
|
65
|
-
postEditVerify: Type.Optional(Type.Boolean({
|
|
66
|
-
description: "Verify persisted content after write",
|
|
67
|
-
})),
|
|
68
|
-
},
|
|
69
|
-
{ additionalProperties: true },
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
type HashlineParams = Static<typeof hashlineEditSchema>;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
function buildEditError(
|
|
76
|
-
path: string,
|
|
77
|
-
code: string,
|
|
78
|
-
message: string,
|
|
79
|
-
hint?: string,
|
|
80
|
-
errorDetails?: Record<string, unknown>,
|
|
81
|
-
): {
|
|
82
|
-
content: [{ type: "text"; text: string }];
|
|
83
|
-
isError: true;
|
|
84
|
-
details: EditToolDetails & { readSeekValue: any };
|
|
85
|
-
} {
|
|
86
|
-
return {
|
|
87
|
-
content: [{ type: "text", text: message }],
|
|
88
|
-
isError: true,
|
|
89
|
-
details: {
|
|
90
|
-
diff: "",
|
|
91
|
-
patch: "",
|
|
92
|
-
firstChangedLine: undefined,
|
|
93
|
-
readSeekValue: {
|
|
94
|
-
tool: "edit",
|
|
95
|
-
ok: false,
|
|
96
|
-
path,
|
|
97
|
-
error: buildReadSeekError(code, message, hint, errorDetails),
|
|
98
|
-
},
|
|
99
|
-
} as EditToolDetails & { readSeekValue: any },
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function mapEditFileError(err: any, filePath: string, _displayPath: string, _phase: "read" | "write"): ReturnType<typeof buildEditError> {
|
|
104
|
-
const { code, message } = formatFsError(err, "edit-error");
|
|
105
|
-
return buildEditError(filePath, code, message);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export interface EditToolOptions {
|
|
109
|
-
wasReadInSession?: FreshAnchorsPredicate;
|
|
110
|
-
onFileMutated?: FileMutatedCallback;
|
|
111
|
-
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
112
|
-
name?: string;
|
|
113
|
-
toolAliases?: Readonly<Record<string, string>>;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface ExecuteEditOptions {
|
|
117
|
-
params: unknown;
|
|
118
|
-
signal: AbortSignal | undefined;
|
|
119
|
-
cwd: string;
|
|
120
|
-
wasReadInSession?: FreshAnchorsPredicate;
|
|
121
|
-
onFileMutated?: FileMutatedCallback;
|
|
122
|
-
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
|
|
126
|
-
const { params, signal, cwd, wasReadInSession, onFileMutated, syntaxValidate } = opts;
|
|
127
|
-
await ensureHashInit();
|
|
128
|
-
const parsed = params as HashlineParams;
|
|
129
|
-
const input = params as Record<string, unknown>;
|
|
130
|
-
const rawPath = parsed.path;
|
|
131
|
-
const path = rawPath.replace(/^@/, "");
|
|
132
|
-
const absolutePath = resolveToCwd(path, cwd);
|
|
133
|
-
throwIfAborted(signal);
|
|
134
|
-
try {
|
|
135
|
-
return await withFileMutationQueue(absolutePath, async () => {
|
|
136
|
-
throwIfAborted(signal);
|
|
137
|
-
if (wasReadInSession && !wasReadInSession(absolutePath)) {
|
|
138
|
-
const message = [
|
|
139
|
-
`You must get fresh anchors for ${absolutePath} before editing it.`,
|
|
140
|
-
`Call readSeek_read(${JSON.stringify(rawPath)}) first, or use readSeek_grep, readSeek_search, or readSeek_write to produce fresh anchors for this file.`,
|
|
141
|
-
"readSeek_edit requires fresh LINE:HASH anchors from readSeek_read, readSeek_grep, readSeek_search, or readSeek_write so the hashes match the current file contents.",
|
|
142
|
-
].join(" ");
|
|
143
|
-
return buildEditError(
|
|
144
|
-
absolutePath,
|
|
145
|
-
"file-not-read",
|
|
146
|
-
message,
|
|
147
|
-
`Call readSeek_read(${JSON.stringify(rawPath)}) first, or use readSeek_grep, readSeek_search, or readSeek_write to produce fresh anchors for this file.`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
const hasTopLevelReplaceInput =
|
|
151
|
-
typeof input.oldText === "string" ||
|
|
152
|
-
typeof input.newText === "string" ||
|
|
153
|
-
typeof input.old_text === "string" ||
|
|
154
|
-
typeof input.new_text === "string";
|
|
155
|
-
if (hasTopLevelReplaceInput) {
|
|
156
|
-
return buildEditError(
|
|
157
|
-
absolutePath,
|
|
158
|
-
"invalid-edit-variant",
|
|
159
|
-
"Top-level oldText/newText and old_text/new_text are no longer supported. Use edits[0].replace instead.",
|
|
160
|
-
"Use edits: [{ replace: { old_text, new_text } }].",
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const edits = parsed.edits ?? [];
|
|
165
|
-
|
|
166
|
-
if (!edits.length) {
|
|
167
|
-
return buildEditError(absolutePath, "invalid-edit-variant", "No edits provided.");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Validate edit variant keys
|
|
171
|
-
for (let i = 0; i < edits.length; i++) {
|
|
172
|
-
throwIfAborted(signal);
|
|
173
|
-
const e = edits[i] as Record<string, unknown>;
|
|
174
|
-
if (("old_text" in e || "new_text" in e) && !("replace" in e)) {
|
|
175
|
-
const message = `edits[${i}] has top-level 'old_text'/'new_text'. Use {replace: {old_text, new_text}} or {set_line}, {replace_lines}, {insert_after}.`;
|
|
176
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
177
|
-
}
|
|
178
|
-
if ("diff" in e) {
|
|
179
|
-
const message = `edits[${i}] contains 'diff' from patch mode. Hashline edit expects one of: {set_line}, {replace_lines}, {insert_after}, {replace}.`;
|
|
180
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
181
|
-
}
|
|
182
|
-
const variantCount =
|
|
183
|
-
Number("set_line" in e) +
|
|
184
|
-
Number("replace_lines" in e) +
|
|
185
|
-
Number("insert_after" in e) +
|
|
186
|
-
Number("replace" in e) +
|
|
187
|
-
Number("replace_symbol" in e);
|
|
188
|
-
if (variantCount !== 1) {
|
|
189
|
-
const message = `edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', 'replace', 'replace_symbol'. Got: [${Object.keys(e).join(", ")}].`;
|
|
190
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const anchorEdits = edits.filter(
|
|
195
|
-
(e): e is HashlineEditItem => "set_line" in e || "replace_lines" in e || "insert_after" in e,
|
|
196
|
-
);
|
|
197
|
-
const replaceEdits = edits.filter(
|
|
198
|
-
(e): e is { replace: { old_text: string; new_text: string; all?: boolean; fuzzy?: boolean } } => "replace" in e,
|
|
199
|
-
);
|
|
200
|
-
const replaceSymbolEdits = edits.filter(
|
|
201
|
-
(e): e is { replace_symbol: { symbol: string; new_body: string } } => "replace_symbol" in e,
|
|
202
|
-
);
|
|
203
|
-
for (const rs of replaceSymbolEdits) {
|
|
204
|
-
if (!rs.replace_symbol.new_body.trim()) {
|
|
205
|
-
return buildEditError(absolutePath, "invalid-edit-variant", "replace_symbol.new_body must not be empty or whitespace-only.");
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
let rawBuffer: Buffer;
|
|
210
|
-
try {
|
|
211
|
-
rawBuffer = await fsReadFile(absolutePath);
|
|
212
|
-
} catch (err: any) {
|
|
213
|
-
return mapEditFileError(err, absolutePath, path, "read");
|
|
214
|
-
}
|
|
215
|
-
if (looksLikeBinary(rawBuffer)) {
|
|
216
|
-
const message = `Cannot edit binary file: ${path}`;
|
|
217
|
-
return buildEditError(absolutePath, "binary-file", message);
|
|
218
|
-
}
|
|
219
|
-
throwIfAborted(signal);
|
|
220
|
-
const raw = rawBuffer.toString("utf-8");
|
|
221
|
-
const { bom, text: content } = stripBom(raw);
|
|
222
|
-
const originalEnding = detectLineEnding(content);
|
|
223
|
-
const originalNormalized = normalizeToLF(content);
|
|
224
|
-
const origLines = originalNormalized.split("\n");
|
|
225
|
-
const anchorSnapshots = new Map<string, string>();
|
|
226
|
-
for (const edit of anchorEdits) {
|
|
227
|
-
const refs: string[] = [];
|
|
228
|
-
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
|
229
|
-
else if ("replace_lines" in edit) {
|
|
230
|
-
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
|
231
|
-
} else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
|
232
|
-
for (const ref of refs) {
|
|
233
|
-
try {
|
|
234
|
-
const parsed = parseLineRef(ref);
|
|
235
|
-
if (parsed.line >= 1 && parsed.line <= origLines.length) {
|
|
236
|
-
const lineContent = origLines[parsed.line - 1];
|
|
237
|
-
const hash = computeLineHash(lineContent);
|
|
238
|
-
anchorSnapshots.set(ref, `${parsed.line}:${hash}|${escapeControlCharsForDisplay(lineContent)}`);
|
|
239
|
-
}
|
|
240
|
-
} catch {
|
|
241
|
-
/* skip malformed refs */
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
let preAnchorContent = originalNormalized;
|
|
246
|
-
// reject anchored edits that target a line inside any replace_symbol
|
|
247
|
-
// pre-replace range. Resolve each target against the ORIGINAL content so the
|
|
248
|
-
// user-provided anchor line numbers (which reference the file as read) are
|
|
249
|
-
// compared against the pre-replace coordinates.
|
|
250
|
-
//
|
|
251
|
-
// surface replace_symbol symbol-resolution errors (not-found, ambiguous)
|
|
252
|
-
// before the overlap check and before any write.
|
|
253
|
-
// Error-precedence order: replace_symbol resolution > anchor-overlap > anchored-edit.
|
|
254
|
-
//
|
|
255
|
-
// store successful probe results and reuse them in the apply loop so
|
|
256
|
-
// readSeekMapContent is invoked at most once per replace_symbol edit.
|
|
257
|
-
const replaceSymbolRanges: { start: number; end: number }[] = [];
|
|
258
|
-
const rsProbeResults: { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }[] = [];
|
|
259
|
-
try {
|
|
260
|
-
for (const rs of replaceSymbolEdits) {
|
|
261
|
-
throwIfAborted(signal);
|
|
262
|
-
const probe = await replaceSymbol({
|
|
263
|
-
filePath: absolutePath,
|
|
264
|
-
content: originalNormalized,
|
|
265
|
-
symbol: rs.replace_symbol.symbol,
|
|
266
|
-
newBody: rs.replace_symbol.new_body,
|
|
267
|
-
});
|
|
268
|
-
if (probe.type !== "ok") {
|
|
269
|
-
// symbol-resolution errors surface before the overlap check.
|
|
270
|
-
return buildEditError(absolutePath, "invalid-edit-variant", probe.message);
|
|
271
|
-
}
|
|
272
|
-
rsProbeResults.push(probe);
|
|
273
|
-
replaceSymbolRanges.push(probe.range);
|
|
274
|
-
}
|
|
275
|
-
} catch (err) {
|
|
276
|
-
throwIfAborted(signal);
|
|
277
|
-
const failure = classifyReadSeekFailure(err);
|
|
278
|
-
return buildEditError(absolutePath, failure.code, failure.message, failure.hint);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const sortedReplaceSymbolRanges = [...replaceSymbolRanges].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
282
|
-
for (let i = 1; i < sortedReplaceSymbolRanges.length; i++) {
|
|
283
|
-
const prev = sortedReplaceSymbolRanges[i - 1];
|
|
284
|
-
const current = sortedReplaceSymbolRanges[i];
|
|
285
|
-
if (current.start <= prev.end) {
|
|
286
|
-
const message = `replace_symbol ranges overlap or duplicate (lines ${prev.start}-${prev.end} and ${current.start}-${current.end}).`;
|
|
287
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (replaceSymbolRanges.length > 0) {
|
|
291
|
-
for (const edit of anchorEdits) {
|
|
292
|
-
if ("replace_lines" in edit) {
|
|
293
|
-
let startLine: number | undefined;
|
|
294
|
-
let endLine: number | undefined;
|
|
295
|
-
try {
|
|
296
|
-
startLine = parseLineRef(edit.replace_lines.start_anchor).line;
|
|
297
|
-
endLine = parseLineRef(edit.replace_lines.end_anchor).line;
|
|
298
|
-
} catch {
|
|
299
|
-
// Let the normal anchored edit validation report malformed anchors later.
|
|
300
|
-
}
|
|
301
|
-
if (startLine !== undefined && endLine !== undefined) {
|
|
302
|
-
const lo = Math.min(startLine, endLine);
|
|
303
|
-
const hi = Math.max(startLine, endLine);
|
|
304
|
-
for (const range of replaceSymbolRanges) {
|
|
305
|
-
if (lo <= range.end && hi >= range.start) {
|
|
306
|
-
const message = `replace_lines range ${lo}-${hi} overlaps a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
307
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
const refs: string[] = [];
|
|
313
|
-
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
|
314
|
-
else if ("replace_lines" in edit) {
|
|
315
|
-
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
|
316
|
-
} else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
|
317
|
-
for (const ref of refs) {
|
|
318
|
-
let parsedLine: number | undefined;
|
|
319
|
-
try {
|
|
320
|
-
parsedLine = parseLineRef(ref).line;
|
|
321
|
-
} catch {
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
for (const range of replaceSymbolRanges) {
|
|
325
|
-
if (parsedLine >= range.start && parsedLine <= range.end) {
|
|
326
|
-
const message = `Anchor at line ${parsedLine} falls inside a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
327
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const anchorIntervals: { lo: number; hi: number }[] = [];
|
|
335
|
-
for (const edit of anchorEdits) {
|
|
336
|
-
try {
|
|
337
|
-
if ("set_line" in edit) {
|
|
338
|
-
const line = parseLineRef(edit.set_line.anchor).line;
|
|
339
|
-
anchorIntervals.push({ lo: line, hi: line });
|
|
340
|
-
} else if ("replace_lines" in edit) {
|
|
341
|
-
const start = parseLineRef(edit.replace_lines.start_anchor).line;
|
|
342
|
-
const end = parseLineRef(edit.replace_lines.end_anchor).line;
|
|
343
|
-
anchorIntervals.push({ lo: Math.min(start, end), hi: Math.max(start, end) });
|
|
344
|
-
}
|
|
345
|
-
} catch {
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
const seenAnchorIntervals = new Set<string>();
|
|
349
|
-
const uniqueAnchorIntervals: { lo: number; hi: number }[] = [];
|
|
350
|
-
for (const interval of anchorIntervals) {
|
|
351
|
-
const key = `${interval.lo}:${interval.hi}`;
|
|
352
|
-
if (seenAnchorIntervals.has(key)) continue;
|
|
353
|
-
seenAnchorIntervals.add(key);
|
|
354
|
-
uniqueAnchorIntervals.push(interval);
|
|
355
|
-
}
|
|
356
|
-
uniqueAnchorIntervals.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
|
|
357
|
-
for (let i = 1; i < uniqueAnchorIntervals.length; i++) {
|
|
358
|
-
const prev = uniqueAnchorIntervals[i - 1];
|
|
359
|
-
const current = uniqueAnchorIntervals[i];
|
|
360
|
-
if (current.lo <= prev.hi) {
|
|
361
|
-
const message = `Anchored edits overlap (lines ${prev.lo}-${prev.hi} and ${current.lo}-${current.hi}). Split into separate, non-overlapping edits or re-read for fresh anchors.`;
|
|
362
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
// Apply pass: reuse all probe results. The probe pass resolved every
|
|
366
|
-
// replace_symbol against originalNormalized; apply those replacements in
|
|
367
|
-
// reverse source order so original line ranges stay valid and no second
|
|
368
|
-
// replaceSymbol/readSeekMapContent call is needed.
|
|
369
|
-
const replaceSymbolWarnings: string[] = [];
|
|
370
|
-
if (rsProbeResults.length > 0) {
|
|
371
|
-
const lines = originalNormalized.split("\n");
|
|
372
|
-
for (const probe of rsProbeResults) {
|
|
373
|
-
replaceSymbolWarnings.push(...probe.warnings);
|
|
374
|
-
}
|
|
375
|
-
for (const probe of [...rsProbeResults].sort((a, b) => b.range.start - a.range.start)) {
|
|
376
|
-
lines.splice(
|
|
377
|
-
probe.range.start - 1,
|
|
378
|
-
probe.range.end - probe.range.start + 1,
|
|
379
|
-
...probe.replacement.split("\n"),
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
preAnchorContent = lines.join("\n");
|
|
383
|
-
}
|
|
384
|
-
let result = preAnchorContent;
|
|
385
|
-
|
|
386
|
-
let anchorResult;
|
|
387
|
-
try {
|
|
388
|
-
anchorResult = applyHashlineEdits(result, anchorEdits, signal);
|
|
389
|
-
} catch (err) {
|
|
390
|
-
if (err instanceof HashlineMismatchError) {
|
|
391
|
-
return buildEditError(absolutePath, "hash-mismatch", err.message, undefined, {
|
|
392
|
-
updatedAnchors: err.updatedAnchors,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
throw err;
|
|
396
|
-
}
|
|
397
|
-
result = anchorResult.content;
|
|
398
|
-
|
|
399
|
-
const replaceWarnings: string[] = [];
|
|
400
|
-
for (const r of replaceEdits) {
|
|
401
|
-
throwIfAborted(signal);
|
|
402
|
-
if (!r.replace.old_text.length) {
|
|
403
|
-
const message = "replace.old_text must not be empty.";
|
|
404
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
405
|
-
}
|
|
406
|
-
const rep = replaceText(result, r.replace.old_text, r.replace.new_text, {
|
|
407
|
-
all: r.replace.all ?? false,
|
|
408
|
-
fuzzy: r.replace.fuzzy ?? false,
|
|
409
|
-
});
|
|
410
|
-
if (!rep.count) {
|
|
411
|
-
const message = `Could not find exact text to replace in ${path}.`;
|
|
412
|
-
const hint =
|
|
413
|
-
"Re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits. " +
|
|
414
|
-
"The replace variant is exact-only by default because fuzzy fallback is unverified.";
|
|
415
|
-
return buildEditError(absolutePath, "text-not-found", message, hint);
|
|
416
|
-
}
|
|
417
|
-
if (rep.usedFuzzyMatch) {
|
|
418
|
-
replaceWarnings.push(
|
|
419
|
-
"replace used fuzzy matching because exact old_text was not found; re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits.",
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
result = rep.content;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (originalNormalized === result) {
|
|
426
|
-
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
427
|
-
if (anchorResult.noopEdits?.length) {
|
|
428
|
-
diagnostic +=
|
|
429
|
-
"\n" +
|
|
430
|
-
anchorResult.noopEdits
|
|
431
|
-
.map(
|
|
432
|
-
(e) =>
|
|
433
|
-
`Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${escapeControlCharsForDisplay(e.currentContent)}`,
|
|
434
|
-
)
|
|
435
|
-
.join("\n");
|
|
436
|
-
diagnostic += "\nRe-read the file to see the current state.";
|
|
437
|
-
} else {
|
|
438
|
-
const targetLines = [...new Set(anchorSnapshots.values())];
|
|
439
|
-
if (targetLines.length > 0) {
|
|
440
|
-
const preview = targetLines.slice(0, 5).join("\n");
|
|
441
|
-
diagnostic += `\nThe file currently contains:\n${preview}\nYour edits were normalized back to the original content. Ensure your replacement changes actual code, not just formatting.`;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return buildEditError(absolutePath, "no-op", diagnostic);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
throwIfAborted(signal);
|
|
448
|
-
|
|
449
|
-
// Syntax-regression validator (warn/block/off)
|
|
450
|
-
const syntaxMode = resolveSyntaxValidateMode({ syntaxValidate: syntaxValidate });
|
|
451
|
-
let syntaxWarning: string | undefined;
|
|
452
|
-
if (syntaxMode !== "off") {
|
|
453
|
-
const regression = await validateSyntaxRegression({
|
|
454
|
-
filePath: absolutePath,
|
|
455
|
-
before: originalNormalized,
|
|
456
|
-
after: result,
|
|
457
|
-
}, { signal });
|
|
458
|
-
if (regression) {
|
|
459
|
-
const lines = regression.errorLines.join(", ");
|
|
460
|
-
const message = `syntax-regression: lines ${lines}`;
|
|
461
|
-
// block mode aborts with syntax-regression code; file is left untouched.
|
|
462
|
-
if (syntaxMode === "block") {
|
|
463
|
-
return buildEditError(absolutePath, "syntax-regression", message);
|
|
464
|
-
}
|
|
465
|
-
syntaxWarning = message;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
const writeContent = bom + restoreLineEndings(result, originalEnding);
|
|
469
|
-
try {
|
|
470
|
-
await fsWriteFile(absolutePath, writeContent, "utf-8");
|
|
471
|
-
} catch (err: any) {
|
|
472
|
-
return mapEditFileError(err, absolutePath, path, "write");
|
|
473
|
-
}
|
|
474
|
-
onFileMutated?.(absolutePath);
|
|
475
|
-
|
|
476
|
-
if (input.postEditVerify === true) {
|
|
477
|
-
let verifiedContent: string;
|
|
478
|
-
try {
|
|
479
|
-
const verified = await fsReadFile(absolutePath, "utf-8");
|
|
480
|
-
verifiedContent = verified;
|
|
481
|
-
} catch (err: any) {
|
|
482
|
-
const message = `Edit write completed but post-edit verification failed: could not read ${path} after writing.`;
|
|
483
|
-
return buildEditError(absolutePath, "post-edit-verification-read-failed", message, undefined, {
|
|
484
|
-
fsCode: err?.code,
|
|
485
|
-
fsMessage: err?.message,
|
|
486
|
-
},
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
if (verifiedContent !== writeContent) {
|
|
490
|
-
const message = `Edit write completed but post-edit verification did not confirm the intended content for ${path}. Re-read the file before making follow-up edits.`;
|
|
491
|
-
return buildEditError(absolutePath, "post-edit-verification-mismatch", message, undefined, {
|
|
492
|
-
expectedLength: writeContent.length,
|
|
493
|
-
actualLength: verifiedContent.length,
|
|
494
|
-
},
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const diffResult = generateCompactOrFullDiff(originalNormalized, result);
|
|
500
|
-
const patch = createPatch(path, originalNormalized, result);
|
|
501
|
-
const blockRanges: DiffBlockRange[] = rsProbeResults.map((probe) => ({
|
|
502
|
-
kind: "remove" as const,
|
|
503
|
-
startLine: probe.range.start,
|
|
504
|
-
endLine: probe.range.end,
|
|
505
|
-
}));
|
|
506
|
-
const diffData = buildDiffData({
|
|
507
|
-
path: absolutePath,
|
|
508
|
-
oldContent: originalNormalized,
|
|
509
|
-
newContent: result,
|
|
510
|
-
diff: diffResult.diff,
|
|
511
|
-
...(blockRanges.length ? { blockRanges } : {}),
|
|
512
|
-
});
|
|
513
|
-
const warnings: string[] = [];
|
|
514
|
-
if (anchorResult.warnings?.length) warnings.push(...anchorResult.warnings);
|
|
515
|
-
if (replaceWarnings.length) warnings.push(...replaceWarnings);
|
|
516
|
-
if (replaceSymbolWarnings.length) warnings.push(...replaceSymbolWarnings);
|
|
517
|
-
if (syntaxWarning) warnings.push(syntaxWarning);
|
|
518
|
-
// Semantic classification
|
|
519
|
-
const internalClassification = classifyEdit(originalNormalized, result);
|
|
520
|
-
const semanticSummary: SemanticSummary = {
|
|
521
|
-
classification: internalClassification.classification,
|
|
522
|
-
};
|
|
523
|
-
const builtOutput = buildEditOutput({
|
|
524
|
-
path: absolutePath,
|
|
525
|
-
displayPath: path,
|
|
526
|
-
diff: diffResult.diff,
|
|
527
|
-
patch,
|
|
528
|
-
diffData,
|
|
529
|
-
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
530
|
-
warnings,
|
|
531
|
-
noopEdits: anchorResult.noopEdits ?? [],
|
|
532
|
-
edits,
|
|
533
|
-
semanticSummary,
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
content: [{ type: "text", text: builtOutput.text }],
|
|
538
|
-
details: {
|
|
539
|
-
diff: diffResult.diff,
|
|
540
|
-
patch: builtOutput.patch,
|
|
541
|
-
diffData,
|
|
542
|
-
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
543
|
-
readSeekValue: builtOutput.readSeekValue,
|
|
544
|
-
} as EditToolDetails & {
|
|
545
|
-
diffData: typeof diffData;
|
|
546
|
-
readSeekValue: {
|
|
547
|
-
tool: string;
|
|
548
|
-
ok: boolean;
|
|
549
|
-
path: string;
|
|
550
|
-
summary: string;
|
|
551
|
-
diff: string;
|
|
552
|
-
diffData: typeof diffData;
|
|
553
|
-
firstChangedLine: number | undefined;
|
|
554
|
-
warnings: string[];
|
|
555
|
-
noopEdits: unknown[];
|
|
556
|
-
};
|
|
557
|
-
},
|
|
558
|
-
};
|
|
559
|
-
});
|
|
560
|
-
} catch (err: any) {
|
|
561
|
-
if (typeof err?.code === "string") {
|
|
562
|
-
return mapEditFileError(err, absolutePath, path, "read");
|
|
563
|
-
}
|
|
564
|
-
throw err;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
|
|
570
|
-
const name = options.name ?? "readSeek_edit";
|
|
571
|
-
const promptMetadata = defineToolPromptMetadata({
|
|
572
|
-
promptUrl: new URL("../prompts/edit.md", import.meta.url),
|
|
573
|
-
promptSnippet: "Safely edit with fresh LINE:HASH anchors",
|
|
574
|
-
registeredName: name,
|
|
575
|
-
toolAliases: options.toolAliases,
|
|
576
|
-
});
|
|
577
|
-
const tool = registerReadSeekTool(pi, {
|
|
578
|
-
name,
|
|
579
|
-
label: "Edit",
|
|
580
|
-
description: promptMetadata.description,
|
|
581
|
-
promptSnippet: promptMetadata.promptSnippet,
|
|
582
|
-
promptGuidelines: promptMetadata.promptGuidelines,
|
|
583
|
-
parameters: hashlineEditSchema,
|
|
584
|
-
renderShell: "default" as const,
|
|
585
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
586
|
-
return executeEdit({
|
|
587
|
-
params,
|
|
588
|
-
signal,
|
|
589
|
-
cwd: ctx.cwd,
|
|
590
|
-
wasReadInSession: options.wasReadInSession,
|
|
591
|
-
onFileMutated: options.onFileMutated,
|
|
592
|
-
syntaxValidate: options.syntaxValidate,
|
|
593
|
-
});
|
|
594
|
-
},
|
|
595
|
-
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
596
|
-
const context: { argsComplete?: boolean; executionStarted?: boolean; lastComponent?: any; cwd?: string; state?: Record<string, any>; invalidate?: () => void; width?: number; expanded?: boolean } = rest[0] ?? {};
|
|
597
|
-
const cwd = context.cwd ?? process.cwd();
|
|
598
|
-
const argsComplete = context.argsComplete ?? false;
|
|
599
|
-
const { path: filePath, suffix } = formatEditCallText(args, argsComplete);
|
|
600
|
-
|
|
601
|
-
let text = theme.fg("toolTitle", theme.bold("edit"));
|
|
602
|
-
if (filePath) text += ` ${linkToolPath(theme.fg("accent", filePath), filePath, cwd)}`;
|
|
603
|
-
else text += ` ${theme.fg("toolOutput", "...")}`;
|
|
604
|
-
const counts = Array.isArray(args?.edits) ? countEditTypes(args.edits) : undefined;
|
|
605
|
-
if (counts && counts.total > 0) {
|
|
606
|
-
text += ` ${theme.fg("dim", `(${counts.total} ${counts.total === 1 ? "edit" : "edits"})`)}`;
|
|
607
|
-
} else if (suffix) {
|
|
608
|
-
text += ` ${theme.fg("dim", suffix)}`;
|
|
609
|
-
}
|
|
610
|
-
text = clampLineToWidth(text, context.width);
|
|
611
|
-
// Once execution has started, the pending preview's only job is done:
|
|
612
|
-
// renderResult will carry the story ("↳ edited +N -M" with the same
|
|
613
|
-
// expandable diff). Keeping the "↳ pending edit" sub-line and its
|
|
614
|
-
// preview alongside the final result is just duplicate noise.
|
|
615
|
-
if (context.executionStarted) {
|
|
616
|
-
return upsertTextComponent(context.lastComponent, text);
|
|
617
|
-
}
|
|
618
|
-
const previewKey = buildEditPreviewKey(args ?? {});
|
|
619
|
-
const preview = resolvePendingDiffPreview(
|
|
620
|
-
context,
|
|
621
|
-
EDIT_PENDING_PREVIEW_STATE_KEY,
|
|
622
|
-
previewKey,
|
|
623
|
-
() => buildPendingEditPreviewData(args ?? {}, context.cwd ?? process.cwd()),
|
|
624
|
-
);
|
|
625
|
-
const expanded = !!context.expanded;
|
|
626
|
-
const preview2 = pendingPreviewLines(text, preview, expanded);
|
|
627
|
-
if (preview2.diffData) {
|
|
628
|
-
return upsertDiffComponent(context.lastComponent, { prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true });
|
|
629
|
-
}
|
|
630
|
-
return upsertTextComponent(context.lastComponent, clampLinesToWidth(preview2.lines, context.width).join("\n"));
|
|
631
|
-
},
|
|
632
|
-
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
633
|
-
const { isPartial, isError, expanded, width, context } = resolveRenderResultContext(options, rest);
|
|
634
|
-
|
|
635
|
-
if (isPartial) {
|
|
636
|
-
return renderPendingResult("pending edit", width, theme);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Extract data from result
|
|
640
|
-
const textContent = result.content
|
|
641
|
-
?.filter((c: any) => c.type === "text")
|
|
642
|
-
.map((c: any) => c.text || "")
|
|
643
|
-
.join("\n") ?? "";
|
|
644
|
-
const details = result.details ?? {};
|
|
645
|
-
const diff: string = details.diff ?? "";
|
|
646
|
-
const readSeekValue = details.readSeekValue as {
|
|
647
|
-
warnings?: string[];
|
|
648
|
-
noopEdits?: unknown[];
|
|
649
|
-
} | undefined;
|
|
650
|
-
const warnings = readSeekValue?.warnings ?? [];
|
|
651
|
-
const noopEdits = readSeekValue?.noopEdits ?? [];
|
|
652
|
-
const semanticClassification = (readSeekValue as any)?.semanticSummary?.classification as string | undefined;
|
|
653
|
-
|
|
654
|
-
const info = formatEditResultText({
|
|
655
|
-
isError: isError || !!result.isError,
|
|
656
|
-
diff,
|
|
657
|
-
warnings,
|
|
658
|
-
noopEdits,
|
|
659
|
-
errorText: textContent,
|
|
660
|
-
semanticClassification: semanticClassification as any,
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
const diffData = (details as any).diffData;
|
|
664
|
-
const stats = diffData?.stats ?? { added: 0, removed: 0 };
|
|
665
|
-
let text = "";
|
|
666
|
-
|
|
667
|
-
if (info.noOp) {
|
|
668
|
-
text = summaryLine("no-op", { theme, style: "dim" });
|
|
669
|
-
if (expanded && info.errorText) text += `\n${theme.fg("error", info.errorText)}`;
|
|
670
|
-
} else if (info.errorText) {
|
|
671
|
-
const firstLine = info.errorText.split("\n")[0] || "Error";
|
|
672
|
-
text = summaryLine(expanded ? info.errorText : firstLine, { theme, style: "error" });
|
|
673
|
-
} else {
|
|
674
|
-
const badges: string[] = [`edited +${stats.added} -${stats.removed}`];
|
|
675
|
-
if (info.semanticBadge) badges.push(info.semanticBadge.replace(/^✓\s*/, ""));
|
|
676
|
-
if (info.warningsBadge) badges.push(info.warningsBadge);
|
|
677
|
-
text = summaryLine(badges.join(" • "), { hidden: !!diffData && !expanded, theme, style: "success" });
|
|
678
|
-
if (expanded && diffData) {
|
|
679
|
-
return upsertDiffComponent(context.lastComponent, { prefixLines: text.split("\n"), diffData, theme, expanded: true });
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
683
|
-
},
|
|
684
|
-
});
|
|
685
|
-
return tool;
|
|
686
|
-
}
|