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/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
- try {
196
- db.exec("ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
197
- } catch {}
198
- try {
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");
@@ -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 notFound = mismatches;
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 function resEdit(edit: HTEdit | HTPayloadEdit, warnings?: string[]): HEdit {
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, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
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 HEdit, type HTEdit, type StripWarningLocation } from "./hashline";
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. "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line. Never include the anchor 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
- const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
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,
@@ -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[] = [];
@@ -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 } : {}),
@@ -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
- return `${indent}${symbol.type}${name}${detail}${children} [limit ${symbol.endLine - symbol.startLine + 1}]`;
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
- if (!addRow(symbol.startLine, symbolLabel(symbol, depth))) return false;
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) {
@@ -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, "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line.',
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, "\\n" inserts one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line; the rest of the range is kept byte-for-byte.',
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} Call read() on ${resolvedPath} first.`);
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}. Call read() on ${resolvedPath} for fresh anchors.`);
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 && ref.limit === undefined) return rendered;
276
- const lineLabel = line === undefined ? "" : ` (${line})`;
277
- const limitLabel = ref.limit === undefined ? "" : ` +${ref.limit}`;
278
- const suffix = theme.fg("warning", `${lineLabel}${limitLabel}`);
279
- rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
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
 
@@ -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, type HEdit } from "./hashline";
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, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
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
- const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
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, member ? batchServedFor(member) : servedForPath(preload.absolutePath));
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,
@@ -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 = typeof params.text === "string"
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
  }));
@@ -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 cross-file copy joins the destination file's batch. 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.
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.
@@ -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. Avoid backreferences, nested quantifiers, and quantified alternation. 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
+ 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`: compare both edges of the inserted block in the post-edit diff against the request; a missing or extra `+anchor│` blank row at either boundary is the classic insert slip.
1
+ - `insert`: a missing or extra `+anchor│` blank row at either block edge in the diff is the classic insert slip.
@@ -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. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `text`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `text`).
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.
@@ -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; moving a range in one file to where it already sits reports no change.
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` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors.
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`: pass a served anchor as `offset` with `limit` to read from it without line-number math; an outline row's `[limit N]` is the span to request.
3
- - `read`: an outline row shows a symbol label, not the file line; edit by anchor, and read the anchor (or copy `old_string` from a served row) before `replace_match`.
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.
@@ -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 lines in edits by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. 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.
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; the interior transfers verbatim and the block lands in one commit. To append at the end, use the destination's last served line as `insert_after`.
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 right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
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.