pi-hashline-edit-pro 6.2.0 → 6.3.1
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 +30 -14
- package/package.json +1 -1
- package/src/anchor-registry.ts +22 -5
- package/src/auto-read-all.ts +1 -1
- package/src/batch.ts +127 -41
- package/src/commit.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/copy-move.ts +57 -68
- package/src/edit-common.ts +27 -15
- package/src/grep.ts +50 -118
- package/src/hash-store.ts +13 -12
- package/src/hashline/resolve.ts +13 -2
- package/src/insert.ts +38 -32
- package/src/line-endings.ts +10 -1
- package/src/outline/index.ts +1 -0
- package/src/outline/render.ts +6 -2
- package/src/payload-contract.ts +2 -2
- package/src/read.ts +17 -12
- package/src/replace-match.ts +39 -38
- package/src/replace-undo.ts +1 -1
- package/src/replace.ts +27 -16
- package/tool-prompts/copy.md +1 -1
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert-guidelines.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/read-guidelines.md +2 -2
- package/tool-prompts/read.md +2 -2
- package/tool-prompts/transfer-guidelines.md +1 -1
- package/tool-prompts/undo-last-change-guidelines.md +1 -1
package/src/hash-store.ts
CHANGED
|
@@ -161,6 +161,15 @@ function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
function addColumnIfMissing(db: RawDb, statement: string): void {
|
|
165
|
+
try {
|
|
166
|
+
db.exec(statement);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof Error && /duplicate column/i.test(error.message)) return;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
164
173
|
function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
165
174
|
db.exec("PRAGMA journal_mode = WAL");
|
|
166
175
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
@@ -192,18 +201,10 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
192
201
|
"updated_at INTEGER NOT NULL" +
|
|
193
202
|
")"
|
|
194
203
|
);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
db.exec("ALTER TABLE undo ADD COLUMN mode INTEGER");
|
|
200
|
-
} catch {}
|
|
201
|
-
try {
|
|
202
|
-
db.exec("ALTER TABLE undo ADD COLUMN separators TEXT");
|
|
203
|
-
} catch {}
|
|
204
|
-
try {
|
|
205
|
-
db.exec("ALTER TABLE undo ADD COLUMN result_separators TEXT");
|
|
206
|
-
} catch {}
|
|
204
|
+
addColumnIfMissing(db, "ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
|
|
205
|
+
addColumnIfMissing(db, "ALTER TABLE undo ADD COLUMN mode INTEGER");
|
|
206
|
+
addColumnIfMissing(db, "ALTER TABLE undo ADD COLUMN separators TEXT");
|
|
207
|
+
addColumnIfMissing(db, "ALTER TABLE undo ADD COLUMN result_separators TEXT");
|
|
207
208
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
208
209
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
209
210
|
db.exec("DELETE FROM snapshots");
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -93,7 +93,12 @@ export function fmtMismatchWithHashes(
|
|
|
93
93
|
const out: string[] = [];
|
|
94
94
|
const hashes: string[] = [];
|
|
95
95
|
const servedMap = new Map<string, string>();
|
|
96
|
-
const
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
const notFound = mismatches.filter((mismatch) => {
|
|
98
|
+
if (seen.has(mismatch.ref.hash)) return false;
|
|
99
|
+
seen.add(mismatch.ref.hash);
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
97
102
|
if (notFound.length > 0) {
|
|
98
103
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
99
104
|
out.push(
|
|
@@ -194,7 +199,13 @@ export function stripAnchorRow(
|
|
|
194
199
|
return match[2]!;
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
export
|
|
202
|
+
export type HTextInput = {
|
|
203
|
+
text: string | string[];
|
|
204
|
+
remove_from: string;
|
|
205
|
+
remove_to: string;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export function resEdit(edit: HTextInput, warnings?: string[]): HEdit {
|
|
198
209
|
if (typeof edit.text === "string") {
|
|
199
210
|
assertPayloadItem(edit as unknown as Record<string, unknown>);
|
|
200
211
|
return resolveParsedEdit(edit.remove_from, edit.remove_to, parsePayloadText(edit.text), warnings);
|
package/src/insert.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
4
|
import { execPipeline, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
|
-
import { batchMemberFor,
|
|
6
|
+
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { MAX_HASH_LINES, parseHashRef, parsePayloadText, resEdit, resolveAnchorLine, type Anchor, type
|
|
8
|
+
import { MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, type Anchor, type HTEdit, type StripWarningLocation } from "./hashline";
|
|
9
9
|
import type { LineEnding } from "./normalize";
|
|
10
10
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
11
|
import { withAnchorSession } from "./anchor-registry";
|
|
@@ -28,7 +28,7 @@ const insertDirectionSchema = Type.Union(
|
|
|
28
28
|
);
|
|
29
29
|
const insertTextSchema = Type.String({
|
|
30
30
|
description:
|
|
31
|
-
'The exact text to insert; an empty string inserts one blank line.
|
|
31
|
+
'The exact text to insert; an empty string inserts one blank line. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Never include the anchor line.',
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
const insertPathRequiredSchema = Type.String({
|
|
@@ -97,6 +97,37 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
|
|
|
97
97
|
return { label: "text", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
function planInsertBatchMember(input: BatchPlannerInput): BatchMemberPlan {
|
|
101
|
+
const req = normReq(input.member.args);
|
|
102
|
+
assertInsertReq(req);
|
|
103
|
+
const insertWarnings = literalEscapeHints([req.text], "text");
|
|
104
|
+
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
105
|
+
const basePreload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
|
|
106
|
+
const built = buildInsertEdit(req, basePreload, ref, input.displayPath);
|
|
107
|
+
const resWarnings: string[] = [];
|
|
108
|
+
const hedit = resEdit(built.editParams, resWarnings);
|
|
109
|
+
const stripWarning = insertStripWarning(built.anchorLine, req.direction);
|
|
110
|
+
const effectiveHedit = preserveDeletionSeparators(hedit, input.base.baseLines, input.base.hashes);
|
|
111
|
+
const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
|
|
112
|
+
filePath: input.displayPath,
|
|
113
|
+
servedHashes: input.served,
|
|
114
|
+
signal: input.signal,
|
|
115
|
+
baseFileLines: input.base.baseLines,
|
|
116
|
+
stripWarning,
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
hedit,
|
|
120
|
+
warnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
|
|
121
|
+
planned,
|
|
122
|
+
foldedLines: built.anchorLine === undefined ? 0 : 1,
|
|
123
|
+
direction: req.direction,
|
|
124
|
+
stripWarning,
|
|
125
|
+
contentSeparators: built.contentSeparators,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
registerBatchPlanner("insert", planInsertBatchMember);
|
|
130
|
+
|
|
100
131
|
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
101
132
|
try {
|
|
102
133
|
const normalized = normReq(request);
|
|
@@ -180,12 +211,13 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
180
211
|
assertInsertReq(canonical);
|
|
181
212
|
const req = canonical;
|
|
182
213
|
const insertWarnings: string[] = [...literalEscapeHints([req.text], "text")];
|
|
214
|
+
const member = batchMemberFor(_toolCallId);
|
|
183
215
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
184
216
|
anchor: req.anchor,
|
|
185
217
|
providedPath: req.path,
|
|
186
218
|
cwd: ctx.cwd,
|
|
219
|
+
anchorTarget: member?.target,
|
|
187
220
|
}).catch((error: unknown) => {
|
|
188
|
-
const member = batchMemberFor(_toolCallId);
|
|
189
221
|
if (member) noteBatchFailure(member, error);
|
|
190
222
|
throw error;
|
|
191
223
|
});
|
|
@@ -193,40 +225,14 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
193
225
|
let anchorWarnings: string[];
|
|
194
226
|
try {
|
|
195
227
|
({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
|
|
196
|
-
await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
|
|
228
|
+
if (member === undefined) await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
|
|
197
229
|
} catch (error) {
|
|
198
|
-
const member = batchMemberFor(_toolCallId);
|
|
199
230
|
if (member) noteBatchFailure(member, error);
|
|
200
231
|
throw error;
|
|
201
232
|
}
|
|
202
233
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
203
|
-
const member = batchMemberFor(_toolCallId);
|
|
204
234
|
if (member) {
|
|
205
|
-
|
|
206
|
-
const basePreload = { normalized: base.content, fileHashes: base.hashes } as NormFile;
|
|
207
|
-
const built = buildInsertEdit(req, basePreload, ref, targetPath);
|
|
208
|
-
let hedit: HEdit;
|
|
209
|
-
const resWarnings: string[] = [];
|
|
210
|
-
try {
|
|
211
|
-
hedit = resEdit(built.editParams, resWarnings);
|
|
212
|
-
} catch (error) {
|
|
213
|
-
noteBatchFailure(member, error);
|
|
214
|
-
throw error;
|
|
215
|
-
}
|
|
216
|
-
return executeBatchMember({
|
|
217
|
-
kind: "insert",
|
|
218
|
-
direction: req.direction,
|
|
219
|
-
member,
|
|
220
|
-
targetPath,
|
|
221
|
-
mutationTargetPath,
|
|
222
|
-
cwd: ctx.cwd,
|
|
223
|
-
signal,
|
|
224
|
-
hedit,
|
|
225
|
-
extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
|
|
226
|
-
foldedLines: built.anchorLine === undefined ? 0 : 1,
|
|
227
|
-
stripWarning: insertStripWarning(built.anchorLine, req.direction),
|
|
228
|
-
contentSeparators: built.contentSeparators,
|
|
229
|
-
});
|
|
235
|
+
return executeBatchMember({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
|
|
230
236
|
}
|
|
231
237
|
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
232
238
|
signal,
|
package/src/line-endings.ts
CHANGED
|
@@ -75,9 +75,18 @@ export function separatorsForSpans(
|
|
|
75
75
|
spans: readonly EndingSpan[],
|
|
76
76
|
resultContent: string,
|
|
77
77
|
fallback: LineEnding,
|
|
78
|
+
baseLines?: readonly string[],
|
|
78
79
|
): LineEnding[] {
|
|
80
|
+
const consumed = new Set<number>();
|
|
81
|
+
if (baseLines !== undefined && baseSeparators.length < baseLineCount) {
|
|
82
|
+
for (const span of spans) {
|
|
83
|
+
if (span.replacementCount !== 0 || span.end !== baseLineCount - 1) continue;
|
|
84
|
+
const previous = baseLines[span.start - 1];
|
|
85
|
+
if (previous !== undefined && previous.trim().length > 0) consumed.add(span.start - 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
79
88
|
const mapped = sourceLineMap(baseLineCount, spans).map((source) =>
|
|
80
|
-
source >= 0 && source < baseSeparators.length ? baseSeparators[source]! : "",
|
|
89
|
+
source >= 0 && source < baseSeparators.length && !consumed.has(source) ? baseSeparators[source]! : "",
|
|
81
90
|
);
|
|
82
91
|
const needed = countNewlines(resultContent);
|
|
83
92
|
const result: LineEnding[] = [];
|
package/src/outline/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
|
|
|
23
23
|
languageName: parsed.languageName,
|
|
24
24
|
totalLines: lines.length,
|
|
25
25
|
hashes: input.hashes,
|
|
26
|
+
lines,
|
|
26
27
|
symbols: parsed.symbols,
|
|
27
28
|
...(input.maxRows !== undefined ? { maxRows: input.maxRows } : {}),
|
|
28
29
|
...(input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}),
|
package/src/outline/render.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface SymbolOutlineInput {
|
|
|
27
27
|
languageName?: string;
|
|
28
28
|
totalLines: number;
|
|
29
29
|
hashes: string[];
|
|
30
|
+
lines: string[];
|
|
30
31
|
symbols: OutlineSymbol[];
|
|
31
32
|
maxRows?: number;
|
|
32
33
|
maxDepth?: number;
|
|
@@ -62,7 +63,8 @@ function symbolLabel(symbol: OutlineSymbol, depth: number): string {
|
|
|
62
63
|
? ` ${symbol.detail.length > MAX_DETAIL_CHARS ? `${symbol.detail.slice(0, MAX_DETAIL_CHARS - 3)}...` : symbol.detail}`
|
|
63
64
|
: "";
|
|
64
65
|
const children = symbol.children !== undefined && symbol.children.length > 0 ? ` (${symbol.children.length} children)` : "";
|
|
65
|
-
|
|
66
|
+
const span = symbol.endLine - symbol.startLine + 1;
|
|
67
|
+
return `${indent}${symbol.type}${name}${detail}${children}${span > 1 ? ` [limit ${span}]` : ""}`;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
|
|
@@ -92,7 +94,9 @@ export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
|
|
|
92
94
|
|
|
93
95
|
const walk = (symbols: OutlineSymbol[], depth: number): boolean => {
|
|
94
96
|
for (const symbol of symbols) {
|
|
95
|
-
|
|
97
|
+
const contentLine = symbol.endLine === symbol.startLine ? input.lines[symbol.startLine - 1] : undefined;
|
|
98
|
+
const text = contentLine === undefined ? symbolLabel(symbol, depth) : clipLine(contentLine, PREVIEW_LINE_CHARS);
|
|
99
|
+
if (!addRow(symbol.startLine, text)) return false;
|
|
96
100
|
const children = symbol.children ?? [];
|
|
97
101
|
if (children.length === 0) continue;
|
|
98
102
|
if (depth >= maxDepth) {
|
package/src/payload-contract.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
|
|
|
4
4
|
|
|
5
5
|
const textSchema = Type.String({
|
|
6
6
|
description:
|
|
7
|
-
'The exact text to write in place of the removed range. "" deletes the range
|
|
7
|
+
'The exact text to write in place of the removed range. "" deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line.',
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
const removeFromSchema = Type.String({
|
|
@@ -234,7 +234,7 @@ const replaceMatchOldStringSchema = Type.String({
|
|
|
234
234
|
});
|
|
235
235
|
const replaceMatchNewStringSchema = Type.String({
|
|
236
236
|
description:
|
|
237
|
-
'The exact replacement for every matched occurrence. "" deletes the matches
|
|
237
|
+
'The exact replacement for every matched occurrence. "" deletes the matches. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line; the rest of the range is kept byte-for-byte.',
|
|
238
238
|
});
|
|
239
239
|
const replaceMatchPathRequiredSchema = Type.String({
|
|
240
240
|
description:
|
package/src/read.ts
CHANGED
|
@@ -49,10 +49,11 @@ export function formatPaginationHint(
|
|
|
49
49
|
endLine: number,
|
|
50
50
|
totalLines: number,
|
|
51
51
|
nextOffset: number,
|
|
52
|
+
lastAnchor?: string,
|
|
52
53
|
byteLimit?: number,
|
|
53
54
|
): string {
|
|
54
55
|
const sizeSuffix = byteLimit !== undefined ? ` (${formatSize(byteLimit)} limit)` : "";
|
|
55
|
-
return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${nextOffset} to continue.]`;
|
|
56
|
+
return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${lastAnchor ?? nextOffset} to continue.]`;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export async function fmtReadPreview(
|
|
@@ -147,7 +148,7 @@ export async function fmtReadPreview(
|
|
|
147
148
|
let nextOffset: number | undefined;
|
|
148
149
|
if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
|
|
149
150
|
nextOffset = lastShownLine + 1;
|
|
150
|
-
preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
|
|
151
|
+
preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, servedHashes.at(-1), skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
|
|
151
152
|
} else {
|
|
152
153
|
preview += `\n\n${warning}`;
|
|
153
154
|
}
|
|
@@ -175,13 +176,13 @@ export async function fmtReadPreview(
|
|
|
175
176
|
const endLineDisplay = startLine + truncation.outputLines - 1;
|
|
176
177
|
nextOffset = endLineDisplay + 1;
|
|
177
178
|
if (truncation.truncatedBy === "lines") {
|
|
178
|
-
preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset)}`;
|
|
179
|
+
preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, servedHashes.at(-1))}`;
|
|
179
180
|
} else {
|
|
180
|
-
preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, truncation.maxBytes)}`;
|
|
181
|
+
preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, servedHashes.at(-1), truncation.maxBytes)}`;
|
|
181
182
|
}
|
|
182
183
|
} else if (endIdx < totalLines) {
|
|
183
184
|
nextOffset = endIdx + 1;
|
|
184
|
-
preview += `\n\n${formatPaginationHint(startLine, endIdx, totalLines, nextOffset)}`;
|
|
185
|
+
preview += `\n\n${formatPaginationHint(startLine, endIdx, totalLines, nextOffset, servedHashes.at(-1))}`;
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
return {
|
|
@@ -209,10 +210,10 @@ export function resolveReadOffset(
|
|
|
209
210
|
if (owner === undefined) {
|
|
210
211
|
const folded = ownersDifferingOnlyByCase(ref.hash);
|
|
211
212
|
const hint = folded.length > 0 ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.` : "";
|
|
212
|
-
throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is not owned in this session.${hint}
|
|
213
|
+
throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is not owned in this session.${hint} Use a line number, or read again for fresh anchors.`);
|
|
213
214
|
}
|
|
214
215
|
if (owner.path !== resolvedPath) {
|
|
215
|
-
throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is owned by ${owner.path}.
|
|
216
|
+
throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is owned by ${owner.path}. Use a line number, or read ${owner.path} to use that anchor.`);
|
|
216
217
|
}
|
|
217
218
|
try {
|
|
218
219
|
return resolveAnchorLine(ref, fileLines, fileHashes, resolvedPath);
|
|
@@ -272,11 +273,15 @@ function renderReadCall(args: unknown, theme: any, context: any): Text {
|
|
|
272
273
|
const index = text.lastIndexOf(marker);
|
|
273
274
|
if (index < 0) return rendered;
|
|
274
275
|
const line = anchorLineOf(ref.anchor, ref.path, context?.cwd);
|
|
275
|
-
if (line === undefined
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
276
|
+
if (line === undefined) {
|
|
277
|
+
if (ref.limit === undefined) return rendered;
|
|
278
|
+
const suffix = theme.fg("warning", ` +${ref.limit}`);
|
|
279
|
+
rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
|
|
280
|
+
return rendered;
|
|
281
|
+
}
|
|
282
|
+
const range = ref.limit === undefined ? `${line}` : `${line}-${line + ref.limit - 1}`;
|
|
283
|
+
const replacement = theme.fg("warning", `:${range} (${ref.anchor})`);
|
|
284
|
+
rendered.setText(`${text.slice(0, index)}${replacement}${text.slice(index + marker.length)}`);
|
|
280
285
|
return rendered;
|
|
281
286
|
}
|
|
282
287
|
|
package/src/replace-match.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
|
|
4
4
|
import { commitEdit } from "./commit";
|
|
5
5
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
6
|
-
import { fmtRegion, MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor
|
|
6
|
+
import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
7
7
|
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
8
|
-
import { batchMemberFor,
|
|
8
|
+
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
9
9
|
import { loadP, loadGuide } from "./prompts";
|
|
10
10
|
import {
|
|
11
11
|
assertReplaceMatchReq,
|
|
@@ -109,6 +109,33 @@ export function buildReplaceMatchEdit(
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan {
|
|
113
|
+
const req = normReq(input.member.args, "replace");
|
|
114
|
+
assertReplaceMatchReq(req);
|
|
115
|
+
const { refs, warnings } = parseMatchAnchors(req);
|
|
116
|
+
const hints = [...literalEscapeHints([req.old_string], "old_string"), ...literalEscapeHints([req.new_string], "new_string")];
|
|
117
|
+
const preload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
|
|
118
|
+
const matchPlan = buildReplaceMatchEdit(req, refs, preload, input.displayPath, input.served);
|
|
119
|
+
const resWarnings: string[] = [];
|
|
120
|
+
const hedit = resEdit(matchPlan.editParams, resWarnings);
|
|
121
|
+
const effectiveHedit = preserveDeletionSeparators(hedit, input.base.baseLines, input.base.hashes);
|
|
122
|
+
const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
|
|
123
|
+
filePath: input.displayPath,
|
|
124
|
+
servedHashes: matchPlan.servedOverride ?? input.served,
|
|
125
|
+
signal: input.signal,
|
|
126
|
+
baseFileLines: input.base.baseLines,
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
hedit,
|
|
130
|
+
warnings: [...warnings, ...hints, ...resWarnings],
|
|
131
|
+
planned,
|
|
132
|
+
foldedLines: 0,
|
|
133
|
+
...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
registerBatchPlanner("replace_match", planReplaceMatchBatchMember);
|
|
138
|
+
|
|
112
139
|
export async function replaceMatchPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
113
140
|
try {
|
|
114
141
|
const normalized = normReq(request, "replace");
|
|
@@ -173,13 +200,13 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
173
200
|
const normalized = normReq(params, "replace");
|
|
174
201
|
assertReplaceMatchReq(normalized);
|
|
175
202
|
const req = normalized;
|
|
203
|
+
const member = batchMemberFor(_toolCallId);
|
|
176
204
|
let refs: MatchRefs;
|
|
177
205
|
let warnings: string[];
|
|
178
206
|
try {
|
|
179
207
|
({ refs, warnings } = parseMatchAnchors(req));
|
|
180
|
-
await throwIfStrictInput(warnings);
|
|
208
|
+
if (member === undefined) await throwIfStrictInput(warnings);
|
|
181
209
|
} catch (error) {
|
|
182
|
-
const member = batchMemberFor(_toolCallId);
|
|
183
210
|
if (member) noteBatchFailure(member, error);
|
|
184
211
|
throw error;
|
|
185
212
|
}
|
|
@@ -189,53 +216,27 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
189
216
|
removeTo: req.replace_to,
|
|
190
217
|
providedPath: req.path,
|
|
191
218
|
cwd: ctx.cwd,
|
|
219
|
+
anchorTarget: member?.target,
|
|
192
220
|
}).catch((error: unknown) => {
|
|
193
|
-
const member = batchMemberFor(_toolCallId);
|
|
194
221
|
if (member) noteBatchFailure(member, error);
|
|
195
222
|
throw error;
|
|
196
223
|
});
|
|
197
224
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
198
|
-
const member = batchMemberFor(_toolCallId);
|
|
199
|
-
let preload: NormFile;
|
|
200
225
|
if (member) {
|
|
201
|
-
|
|
202
|
-
preload = { normalized: base.content, fileHashes: base.hashes } as NormFile;
|
|
203
|
-
} else {
|
|
204
|
-
preload = await readNormFile(targetPath, ctx.cwd, {
|
|
205
|
-
signal,
|
|
206
|
-
accessMode: constants.R_OK | constants.W_OK,
|
|
207
|
-
maxLines: MAX_HASH_LINES,
|
|
208
|
-
});
|
|
226
|
+
return executeBatchMember({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
|
|
209
227
|
}
|
|
228
|
+
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
229
|
+
signal,
|
|
230
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
231
|
+
maxLines: MAX_HASH_LINES,
|
|
232
|
+
});
|
|
210
233
|
let plan: MatchPlan;
|
|
211
234
|
try {
|
|
212
|
-
plan = buildReplaceMatchEdit(req, refs, preload, targetPath,
|
|
235
|
+
plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
|
|
213
236
|
} catch (error) {
|
|
214
237
|
await noteAnchorError(mutationTargetPath, error);
|
|
215
|
-
if (member) noteBatchFailure(member, error);
|
|
216
238
|
throw error;
|
|
217
239
|
}
|
|
218
|
-
if (member) {
|
|
219
|
-
let hedit: HEdit;
|
|
220
|
-
const resWarnings: string[] = [];
|
|
221
|
-
try {
|
|
222
|
-
hedit = resEdit(plan.editParams, resWarnings);
|
|
223
|
-
} catch (error) {
|
|
224
|
-
noteBatchFailure(member, error);
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
return executeBatchMember({
|
|
228
|
-
kind: "replace_match",
|
|
229
|
-
member,
|
|
230
|
-
targetPath,
|
|
231
|
-
mutationTargetPath,
|
|
232
|
-
cwd: ctx.cwd,
|
|
233
|
-
signal,
|
|
234
|
-
hedit,
|
|
235
|
-
extraWarnings: [...warnings, ...hints, ...resWarnings],
|
|
236
|
-
...(plan.servedOverride !== undefined ? { servedOverride: plan.servedOverride } : {}),
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
240
|
const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
|
|
240
241
|
accessMode: constants.R_OK | constants.W_OK,
|
|
241
242
|
signal,
|
package/src/replace-undo.ts
CHANGED
|
@@ -184,7 +184,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
184
184
|
);
|
|
185
185
|
|
|
186
186
|
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|
|
187
|
-
const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
|
|
187
|
+
const currentHashes = await lineHashes(currentNormalized, mutationTargetPath, undefined, undefined, false, true);
|
|
188
188
|
const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
|
|
189
189
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
190
190
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
package/src/replace.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { loadP, loadGuide } from "./prompts";
|
|
|
16
16
|
import { type FileIdentity } from "./fs-write";
|
|
17
17
|
import { applyEdit,
|
|
18
18
|
lineHashes,
|
|
19
|
+
planEdit,
|
|
19
20
|
resEdit,
|
|
20
21
|
preserveDeletionSeparators,
|
|
21
22
|
MAX_HASH_LINES,
|
|
@@ -36,7 +37,7 @@ import { resolveTarget } from "./fs-write";
|
|
|
36
37
|
import { toCwd, toDisplayPath } from "./paths";
|
|
37
38
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withReplacePrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
38
39
|
import { commitEdit } from "./commit";
|
|
39
|
-
import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
|
|
40
|
+
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
40
41
|
|
|
41
42
|
export { editToolSchema, type ReqParams, assertReq };
|
|
42
43
|
|
|
@@ -126,12 +127,32 @@ function countLineChanges(
|
|
|
126
127
|
export function buildReplaceHEdit(params: RawReqParams): { edit: HEdit; warnings: string[] } {
|
|
127
128
|
const editWarnings: string[] = [];
|
|
128
129
|
const anchors = { remove_from: params.remove_from, remove_to: params.remove_to };
|
|
129
|
-
const edit =
|
|
130
|
-
? resEdit({ ...anchors, text: params.text }, editWarnings)
|
|
131
|
-
: resEdit({ ...anchors, text: params.text }, editWarnings);
|
|
130
|
+
const edit = resEdit({ ...anchors, text: params.text }, editWarnings);
|
|
132
131
|
return { edit, warnings: editWarnings };
|
|
133
132
|
}
|
|
134
133
|
|
|
134
|
+
function planReplaceBatchMember(input: BatchPlannerInput): BatchMemberPlan {
|
|
135
|
+
const req = normReq(input.member.args, "remove");
|
|
136
|
+
assertReq(req);
|
|
137
|
+
const literalEscapes = literalEscapeHints([req.text], "text");
|
|
138
|
+
const built = buildReplaceHEdit(req);
|
|
139
|
+
const effectiveHedit = preserveDeletionSeparators(built.edit, input.base.baseLines, input.base.hashes);
|
|
140
|
+
const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
|
|
141
|
+
filePath: input.displayPath,
|
|
142
|
+
servedHashes: input.served,
|
|
143
|
+
signal: input.signal,
|
|
144
|
+
baseFileLines: input.base.baseLines,
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
hedit: built.edit,
|
|
148
|
+
warnings: [...literalEscapes, ...built.warnings],
|
|
149
|
+
planned,
|
|
150
|
+
foldedLines: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
registerBatchPlanner("replace", planReplaceBatchMember);
|
|
155
|
+
|
|
135
156
|
function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[] | undefined): HEdit {
|
|
136
157
|
if (overrides === undefined) return edit;
|
|
137
158
|
const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
|
|
@@ -292,18 +313,18 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
292
313
|
assertReq(canonical);
|
|
293
314
|
const normalizedParams = canonical;
|
|
294
315
|
const literalEscapes = literalEscapeHints([normalizedParams.text], "text");
|
|
316
|
+
const member = batchMemberFor(_toolCallId);
|
|
295
317
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
296
318
|
removeFrom: normalizedParams.remove_from,
|
|
297
319
|
removeTo: normalizedParams.remove_to,
|
|
298
320
|
providedPath: normalizedParams.path,
|
|
299
321
|
cwd: ctx.cwd,
|
|
322
|
+
anchorTarget: member?.target,
|
|
300
323
|
}).catch((error: unknown) => {
|
|
301
|
-
const member = batchMemberFor(_toolCallId);
|
|
302
324
|
if (member) noteBatchFailure(member, error);
|
|
303
325
|
throw error;
|
|
304
326
|
});
|
|
305
327
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
306
|
-
const member = batchMemberFor(_toolCallId);
|
|
307
328
|
if (!member) {
|
|
308
329
|
const pipe = await execPipeline(
|
|
309
330
|
targetPath,
|
|
@@ -320,22 +341,12 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
320
341
|
signal,
|
|
321
342
|
});
|
|
322
343
|
}
|
|
323
|
-
let built: { edit: HEdit; warnings: string[] };
|
|
324
|
-
try {
|
|
325
|
-
built = buildReplaceHEdit(normalizedParams);
|
|
326
|
-
} catch (error) {
|
|
327
|
-
noteBatchFailure(member, error);
|
|
328
|
-
throw error;
|
|
329
|
-
}
|
|
330
344
|
return executeBatchMember({
|
|
331
|
-
kind: "replace",
|
|
332
345
|
member,
|
|
333
346
|
targetPath,
|
|
334
347
|
mutationTargetPath,
|
|
335
348
|
cwd: ctx.cwd,
|
|
336
349
|
signal,
|
|
337
|
-
hedit: built.edit,
|
|
338
|
-
extraWarnings: [...literalEscapes, ...built.warnings],
|
|
339
350
|
});
|
|
340
351
|
});
|
|
341
352
|
}));
|
package/tool-prompts/copy.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A copy always duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
package/tool-prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Patterns are regex by default; set `literal: true` to search exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows.
|
|
1
|
+
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Patterns are regex by default; set `literal: true` to search exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows. Backreferences and look-around are rejected by ripgrep's engine; nested quantifiers and alternation run in linear time. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `insert`:
|
|
1
|
+
- `insert`: a missing or extra `+anchor│` blank row at either block edge in the diff is the classic insert slip.
|
package/tool-prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert text after or before one existing line in a text file, addressed by the `anchor` field (a bare anchor from any served anchor│content row). `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert
|
|
1
|
+
Insert text after or before one existing line in a text file, addressed by the `anchor` field (a bare anchor from any served anchor│content row). `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert; a trailing line break sets the newline of the last line written instead of adding a blank line, and a leading break is an ordinary blank line.
|
package/tool-prompts/move.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `insert_after` must sit outside the source range when both anchors share a file
|
|
1
|
+
Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `insert_after` must sit outside the source range when both anchors share a file.
|
|
2
2
|
|
|
3
|
-
A cross-file `move`
|
|
3
|
+
A cross-file `move` commits on its own when its source file also has batched edits in the message.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
|
-
- `read`:
|
|
3
|
-
- `read`:
|
|
2
|
+
- `read`: an auto-read-all outline row shows a symbol label, or the line content for a single-line symbol; its `[limit N]` is the span to request.
|
|
3
|
+
- `read`: `replace_match` needs both boundary lines served; copy `old_string` from a served row or read the lines first.
|
package/tool-prompts/read.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target
|
|
1
|
+
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target edits by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue from the hinted `offset`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
2
2
|
|
|
3
|
-
`offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it. A stale anchor is refused with the current context rows and fresh anchors.
|
|
3
|
+
`offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it, so no line-number math is needed. A stale anchor is refused with the current context rows and fresh anchors.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
- `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving;
|
|
1
|
+
- `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving; to append at the end, use the destination's last served line as `insert_after`.
|
|
2
2
|
- `copy`/`move`: `insert_after` must sit outside the source range; `source_to` places the block right after itself.
|
|
3
3
|
- `copy`/`move`: `source_from` and `source_to` may live in a different file than `insert_after`; an empty destination file is seeded with the copied or moved lines.
|
|
4
4
|
- `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
|
|
2
|
-
- `undo_last_change`: a `write` clears the history, so undo
|
|
2
|
+
- `undo_last_change`: a `write` clears the history, so undo before any other edit or write; the undo diff's `+anchor│` rows are the restored lines.
|
|
3
3
|
- `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
|