pi-hashline-edit-pro 6.0.0 → 6.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1 +1 @@
1
- Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; the anchors resolve both files
1
+ Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; each anchor resolves its own file
package/prompts/copy.md CHANGED
@@ -1,5 +1,3 @@
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. `source_from` and `source_to` may belong to a different file than `insert_after`: the lines are copied from the source file and inserted into the destination file, and an empty destination file is seeded with the copied lines. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
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
- Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } copies `old` from `a.ts` into `b.ts` below `top`; the source `Hasu│old` row stays live.
4
-
5
- Same-file calls in one message batch, and a cross-file copy joins the destination file's batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole 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 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.
@@ -0,0 +1 @@
1
+ - `anchor_grep`: prefer it over shell `rg`/`grep` for searching, so hits carry anchors and need no `read`; use `literal: true` when the pattern contains regex metacharacters.
package/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. 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. 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.
package/prompts/insert.md CHANGED
@@ -1,3 +1 @@
1
- Insert text after or before one existing line in a text file, addressed by 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. JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. 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`).
2
-
3
- Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
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`).
@@ -0,0 +1 @@
1
+ - `move`: moving a range to where it already sits reports `No changes made` and leaves the anchors alone.
@@ -1 +1 @@
1
- Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; the anchors resolve both files
1
+ Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; each anchor resolves its own file
package/prompts/move.md CHANGED
@@ -1,5 +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. `source_from` and `source_to` may belong to a different file than `insert_after`: the range is removed from the source file and inserted into the destination file in one call, and an empty destination file is seeded with the moved lines. `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; moving a range in one file to where it already sits reports no change.
2
2
 
3
- Example: read served `Hasu│a` in `lib.ts` and `Qwer│top` in `app.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } moves `a` from `lib.ts` into `app.ts` below `top`.
4
-
5
- A same-file `move` and a cross-file `move` join the same-message batch of the destination file; 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` 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.
package/prompts/read.md CHANGED
@@ -1 +1 @@
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 replace and insert 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 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,4 +1,3 @@
1
- - `replace`: same-message calls must target disjoint ranges; a call whose anchors resolve nowhere fails on its own while the rest of the batch still commits.
1
+ - `replace`: same-message calls must target disjoint ranges.
2
2
  - `replace`: a pure deletion (`text: ""`) is the cheap way to clear a large range: it verifies only the first and last line against the served record and removes the interior as it currently stands.
3
- - `replace`: to delete several blocks, `anchor_grep` serves each block's first line and a short `read` around each block serves its closing line; then one message of pure-deletion `replace` calls (`remove_from`/`remove_to` per block, `text: ""`) removes them all as one batch.
4
- - `replace`: for a single line, use the same anchor for `remove_from` and `remove_to`; a pasted `anchor│` prefix in `text` is stripped.
3
+ - `replace`: for a single line, use the same anchor for `remove_from` and `remove_to`.
@@ -1 +1 @@
1
- - `replace_match`: every occurrence of `old_string` inside the range is replaced; scope the range with anchors when only some occurrences should change, and check the post-edit diff.
1
+ - `replace_match`: `old_string` is usually a fragment of the line, not the whole line; every occurrence inside the range is replaced, so scope the range with anchors when only some occurrences should change.
@@ -1,7 +1,3 @@
1
1
  Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `old_string` is the text to find inside that range, and `new_string` replaces every occurrence of it.
2
2
 
3
- Example: read served `Hasu│ {"name": "widget", "size": "small"},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "old_string": "small", "new_string": "large" }. The line becomes ` {"name": "widget", "size": "large"},` and the post-edit diff carries fresh anchors.
4
-
5
- JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. A missing match is refused with the current rows, so the retry needs no read. The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
6
-
7
- Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch. A missing `old_string` aborts the whole batch unwritten.
3
+ The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
@@ -1 +1 @@
1
- Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `text` (one edit per call)
1
+ Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `text`
@@ -1,6 +1,2 @@
1
- Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. The text is written exactly as given, and nothing else in the file changes.
1
+ Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. The text is written exactly as given, and nothing else in the file changes.
2
2
  To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name. Deleting every line empties the file; the result names the new empty-line anchor, so a follow-up `replace` on it can seed content without a `read`.
3
-
4
- Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
5
-
6
- Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "text": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
@@ -1,2 +1,4 @@
1
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`.
2
+ - `copy`/`move`: `insert_after` must sit outside the source range; `source_to` places the block right after itself.
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.
2
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 +1 @@
1
- Undo the last replace, replace_match, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
1
+ Undo the last replace, replace_match, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused and the file is left untouched; do not force it. A file deleted since the edit is restored. If the diff is truncated, use read to see the full file.
package/src/constants.ts CHANGED
@@ -20,7 +20,7 @@ export const TEXT_NOT_STRING_MSG =
20
20
  `[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use "\\n" for one blank line; line breaks inside the string separate lines.`;
21
21
 
22
22
  export const NUL_CONTENT_MSG =
23
- `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range.`;
23
+ `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry.`;
24
24
 
25
25
  export const ANCHOR_POOL_EXHAUSTED_PREFIX =
26
26
  "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
@@ -43,39 +43,63 @@ function preferenceGuideline(flags: EditToolFlags): string {
43
43
  return `Prefer the hashline tools for anything that touches files: ${joinOps(tools, { backtick: true })}.`;
44
44
  }
45
45
 
46
+ const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
47
+ const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
48
+ const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
49
+
50
+ function operationNames(ops: string[], flags: EditToolFlags): string {
51
+ return joinOps(gatedEditOps(ops, flags), { backtick: true, separator: "/" });
52
+ }
53
+
54
+ function batchGuideline(flags: EditToolFlags): string {
55
+ const tools = operationNames(SHARED_EDIT_OPS, flags);
56
+ const outcome = flags.autoRead ? "diff" : "result";
57
+ return `${tools}: same-file calls in one message are grouped into one batch; earlier calls reply \`In batch N (queued)\` and the last call shows the combined ${outcome}, with one undo for the whole batch.`;
58
+ }
59
+
60
+ function diffGuideline(flags: EditToolFlags): string {
61
+ const tools = operationNames(SHARED_DIFF_OPS, flags);
62
+ return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows are live anchors for the next edit.`;
63
+ }
64
+
65
+ function pathGuideline(flags: EditToolFlags): string {
66
+ const tools = operationNames(SHARED_EDIT_OPS, flags);
67
+ return flags.requirePath
68
+ ? `${tools}: pass \`path\` matching the file the anchors were served for; it is required and must match anchor ownership.`
69
+ : `${tools}: path resolution is anchor-only; do not pass \`path\`.`;
70
+ }
71
+
72
+ function payloadGuideline(flags: EditToolFlags): string {
73
+ const tools = operationNames(SHARED_PAYLOAD_OPS, flags);
74
+ return `${tools}: JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — \`\\uXXXX\` is the character, \`\\\\uXXXX\` the literal text.`;
75
+ }
76
+
77
+ function strictInputGuideline(flags: EditToolFlags): string {
78
+ const tools = operationNames(SHARED_EDIT_OPS, flags);
79
+ return `${tools}: strict-input mode is on; auto-fixable slips are rejected instead of fixed with warnings.`;
80
+ }
81
+
46
82
  function finalizePrompts(
47
83
  description: string,
48
84
  snippet: string,
49
85
  guidelines: string[],
50
86
  flags: EditToolFlags,
51
- requirePathNotice: string,
87
+ options?: { stringPayload?: boolean },
52
88
  ): { description: string; snippet: string; guidelines: string[] } {
53
- const descriptionParts = [description];
54
- const snippetParts = [snippet];
55
- if (flags.requirePath) {
56
- descriptionParts.push(requirePathNotice);
57
- snippetParts.push("; include `path` (required)");
58
- } else {
59
- descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
60
- }
61
- if (flags.strictInput) {
62
- descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
63
- }
64
- return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
89
+ const shared = [batchGuideline(flags), pathGuideline(flags)];
90
+ if (flags.autoRead) shared.push(diffGuideline(flags));
91
+ if (options?.stringPayload !== false) shared.push(payloadGuideline(flags));
92
+ if (flags.strictInput) shared.push(strictInputGuideline(flags));
93
+ return { description, snippet, guidelines: [...guidelines, ...shared] };
65
94
  }
66
95
 
67
96
  export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
68
97
  let description = base.description;
69
- let guidelines = [preferenceGuideline(flags), ...base.guidelines];
70
- if (!flags.autoRead) {
71
- description = description.replace(/\n\nExample:[\s\S]*$/, "");
72
- guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
73
- description = description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
74
- }
98
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines];
75
99
  if (!flags.replaceMatchEnabled) {
76
100
  description = description.replace(/\n?To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name\./, "");
77
101
  }
78
- return finalizePrompts(description, base.snippet, guidelines, flags, "Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
102
+ return finalizePrompts(description, base.snippet, guidelines, flags);
79
103
  }
80
104
 
81
105
  export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
@@ -92,14 +116,13 @@ export function withReadPrompts(base: { description: string; snippet: string; gu
92
116
  }
93
117
 
94
118
  export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
95
- const baseDescription = flags.autoRead ? base.description : base.description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
96
119
  const guidelines = [...base.guidelines];
97
- return finalizePrompts(baseDescription, base.snippet, guidelines, flags, "Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
120
+ return finalizePrompts(base.description, base.snippet, guidelines, flags);
98
121
  }
99
122
 
100
123
  export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
101
124
  const guidelines = [...base.guidelines];
102
- return finalizePrompts(base.description, base.snippet, guidelines, flags, "Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
125
+ return finalizePrompts(base.description, base.snippet, guidelines, flags);
103
126
  }
104
127
 
105
128
  function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
@@ -119,7 +142,7 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
119
142
  return formatted.length === 2 ? `${head} or ${last}` : `${head}, or ${last}`;
120
143
  }
121
144
 
122
- export function withGrepPrompts(base: { description: string; snippet: string }, flags: EditToolFlags): { description: string; snippet: string } {
145
+ export function withGrepPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
123
146
  if (flags.copyMoveEnabled) return base;
124
147
  return { ...base, description: base.description.replaceAll("replace, insert, copy, or move", joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags))) };
125
148
  }
@@ -145,7 +168,7 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
145
168
 
146
169
  export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
147
170
  const guidelines = [...base.guidelines];
148
- return finalizePrompts(base.description, base.snippet, guidelines, flags, "Also give `path` matching the source or destination file the anchors were served for; it is required and must match anchor ownership.");
171
+ return finalizePrompts(base.description, base.snippet, guidelines, flags, { stringPayload: false });
149
172
  }
150
173
 
151
174
  function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
package/src/grep.ts CHANGED
@@ -11,7 +11,7 @@ import { globToRegex } from "./glob";
11
11
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP, HASH_CLASS } from "./hashline";
12
12
  import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
13
13
  import { toCwd, toDisplayPath } from "./paths";
14
- import { loadP } from "./prompts";
14
+ import { loadP, loadGuide } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
16
  import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
17
17
  import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, withLineNumbers } from "./utils";
@@ -524,12 +524,17 @@ export function renderGrepResult(result: { content?: Array<{ type: string; text?
524
524
  return reuseText(context, shown.join("\n"));
525
525
  }
526
526
  export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
527
- const prompted = withGrepPrompts({ description: loadP("../prompts/grep.md"), snippet: loadP("../prompts/grep-snippet.md") }, flags);
527
+ const prompted = withGrepPrompts({
528
+ description: loadP("../prompts/grep.md"),
529
+ snippet: loadP("../prompts/grep-snippet.md"),
530
+ guidelines: loadGuide("../prompts/grep-guidelines.md"),
531
+ }, flags);
528
532
  pi.registerTool({
529
533
  name: "anchor_grep",
530
534
  label: "Anchor Grep",
531
535
  description: prompted.description,
532
536
  promptSnippet: prompted.snippet,
537
+ promptGuidelines: prompted.guidelines,
533
538
  prepareArguments: makePrepareArguments(),
534
539
  parameters: grepToolSchema,
535
540
  executionMode: "sequential",
package/src/insert.ts CHANGED
@@ -18,7 +18,7 @@ export { assertInsertReq, type InsertReq };
18
18
 
19
19
  const insertAnchorSchema = Type.String({
20
20
  description:
21
- "4-char anchor of the line to insert next to (never the row content). A pasted diff row is stripped with a warning; the anchor line is preserved.",
21
+ "4-char anchor of the line to insert next to (never the row content).",
22
22
  });
23
23
 
24
24
  const insertDirectionSchema = Type.Union(
@@ -88,7 +88,7 @@ export function buildReplaceMatchEdit(
88
88
  const end = Math.max(fromLine, toLine);
89
89
  assertBoundaryLinesServed(fileLines, preload.fileHashes, served, start, end, displayPath);
90
90
  const rangeLines = fileLines.slice(start - 1, end);
91
- const rangeText = rangeLines.join("\n");
91
+ const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
92
92
  const oldText = toLF(req.old_string);
93
93
  const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
94
94
  if (count === 0) {