pi-hashline-edit-pro 4.5.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ export interface Config {
14
14
  autoRead: boolean;
15
15
  anchorGrepEnabled: boolean;
16
16
  copyMoveEnabled?: boolean;
17
+ replaceWithinEnabled?: boolean;
17
18
  autoReadAll?: AutoReadAllMode;
18
19
  autoReadAllIgnore?: string[];
19
20
  requirePath?: boolean;
@@ -25,6 +26,7 @@ const DEFAULT_CONFIG: Config = {
25
26
  autoRead: true,
26
27
  anchorGrepEnabled: true,
27
28
  copyMoveEnabled: true,
29
+ replaceWithinEnabled: true,
28
30
  autoReadAll: "off",
29
31
  autoReadAllIgnore: [],
30
32
  requirePath: false,
@@ -75,6 +77,7 @@ function parseConfig(content: string): Config {
75
77
  const autoRead = parsed.autoRead;
76
78
  const anchorGrepEnabled = parsed.anchorGrepEnabled;
77
79
  const copyMoveEnabled = parsed.copyMoveEnabled;
80
+ const replaceWithinEnabled = parsed.replaceWithinEnabled;
78
81
  const autoReadAll = parsed.autoReadAll;
79
82
  const requirePath = parsed.requirePath;
80
83
  const strictInput = parsed.strictInput;
@@ -84,6 +87,7 @@ function parseConfig(content: string): Config {
84
87
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
85
88
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
86
89
  copyMoveEnabled: typeof copyMoveEnabled === "boolean" ? copyMoveEnabled : DEFAULT_CONFIG.copyMoveEnabled,
90
+ replaceWithinEnabled: typeof replaceWithinEnabled === "boolean" ? replaceWithinEnabled : DEFAULT_CONFIG.replaceWithinEnabled,
87
91
  autoReadAll: parseAutoReadAllMode(autoReadAll),
88
92
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
89
93
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
@@ -196,7 +200,7 @@ export async function writeConfig(config: Config): Promise<void> {
196
200
  }
197
201
 
198
202
 
199
- type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput";
203
+ type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceWithinEnabled" | "requirePath" | "strictInput";
200
204
 
201
205
  async function toggleFlag(key: ToggleKey): Promise<boolean> {
202
206
  const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
@@ -205,6 +209,7 @@ async function toggleFlag(key: ToggleKey): Promise<boolean> {
205
209
  export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
206
210
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
207
211
  export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
212
+ export const toggleReplaceWithin = (): Promise<boolean> => toggleFlag("replaceWithinEnabled");
208
213
  export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
209
214
  let next: AutoReadAllMode = "off";
210
215
  await updateConfig((c) => {
package/src/constants.ts CHANGED
@@ -13,6 +13,12 @@ export const HASH_STORE_VERSION = 9;
13
13
  export const NEW_CONTENT_NOT_ARRAY_MSG =
14
14
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
15
15
 
16
+ export const NEW_CONTENT_NOT_STRING_MSG =
17
+ `[E_BAD_SHAPE] "replacement_lines" must be a string holding the exact text to write. Use "" to delete the range and "\\n" for one blank line; line breaks inside the string separate lines.`;
18
+
19
+ export const LINES_NOT_STRING_MSG =
20
+ `[E_BAD_SHAPE] "lines" must be a string holding the exact text to insert. Use "" to insert nothing and "\\n" for one blank line; line breaks inside the string separate lines.`;
21
+
16
22
  export const NUL_CONTENT_MSG =
17
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 or inserts nothing.`;
18
24
 
package/src/copy-move.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
2
2
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import { constants } from "node:fs";
5
- import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReqParams, type ReplaceDetails } from "./replace";
5
+ import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
6
6
  import { commitEdit } from "./commit";
7
7
  import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
8
8
  import {
@@ -14,6 +14,7 @@ import {
14
14
  resolveAnchorLine,
15
15
  stripAnchorRow,
16
16
  type Anchor,
17
+ type HTEdit,
17
18
  } from "./hashline";
18
19
  import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAnchorSession } from "./anchor-registry";
19
20
  import { loadP, loadGuide } from "./prompts";
@@ -49,7 +50,7 @@ interface TransferRefs {
49
50
  }
50
51
 
51
52
  export interface TransferPlan {
52
- editParams: ReqParams;
53
+ editParams: HTEdit;
53
54
  foldedAnchorLines: number;
54
55
  anchorCarry?: number;
55
56
  servedOverride?: ReadonlyMap<string, string>;
@@ -224,10 +225,10 @@ interface CrossTransferPreparation {
224
225
  destinationPreload: NormFile;
225
226
  sourceDisplay: string;
226
227
  destinationDisplay: string;
227
- destinationEdit: ReqParams;
228
+ destinationEdit: HTEdit;
228
229
  destinationFolded: number;
229
230
  endingOverrides: (LineEnding | undefined)[];
230
- sourceEdit?: ReqParams;
231
+ sourceEdit?: HTEdit;
231
232
  }
232
233
 
233
234
  async function prepareCrossTransfer(input: {
@@ -292,7 +293,7 @@ async function prepareCrossTransfer(input: {
292
293
  }
293
294
 
294
295
  const moved = sourceLines.slice(sourceStart - 1, sourceEnd);
295
- const destinationEdit: ReqParams = destinationPreload.normalized.length === 0
296
+ const destinationEdit: HTEdit = destinationPreload.normalized.length === 0
296
297
  ? {
297
298
  remove_from: destinationPreload.fileHashes[0]!,
298
299
  remove_to: destinationPreload.fileHashes[0]!,
@@ -309,7 +310,7 @@ async function prepareCrossTransfer(input: {
309
310
  if (kind === "copy") {
310
311
  return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, endingOverrides };
311
312
  }
312
- const sourceEdit: ReqParams = {
313
+ const sourceEdit: HTEdit = {
313
314
  remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
314
315
  remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
315
316
  replacement_lines: [],
@@ -544,6 +545,7 @@ async function executeCrossFile(
544
545
  accessMode: constants.R_OK | constants.W_OK,
545
546
  signal,
546
547
  preloadedNorm: prepared.destinationPreload,
548
+ endingOverrides: prepared.endingOverrides,
547
549
  });
548
550
  if (kind === "copy") {
549
551
  return commitEdit(destinationPipe, {
@@ -610,6 +612,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
610
612
  noPersist: true,
611
613
  preloadedNorm: preload,
612
614
  served: plan.servedOverride,
615
+ endingOverrides: plan.endingOverrides,
613
616
  signal,
614
617
  });
615
618
  return previewFromPipe(pipe);
@@ -619,6 +622,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
619
622
  accessMode: constants.R_OK,
620
623
  noPersist: true,
621
624
  preloadedNorm: prepared.destinationPreload,
625
+ endingOverrides: prepared.endingOverrides,
622
626
  signal,
623
627
  });
624
628
  if (kind === "copy") return previewFromPipe(destinationPipe);
@@ -668,19 +672,19 @@ function getTransferInput(args: unknown): { path?: string; source_from?: string;
668
672
 
669
673
  const transferSourceFromSchema = Type.String({
670
674
  description:
671
- "Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. Marks the FIRST source line (inclusive); this anchor and `source_to` resolve the source file.",
675
+ "4-char anchor of the FIRST source line to copy (never the row content).",
672
676
  });
673
677
  const transferSourceToSchema = Type.String({
674
678
  description:
675
- "Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. Marks the LAST source line (inclusive); this anchor and `source_from` resolve the source file.",
679
+ "4-char anchor of the LAST source line to copy.",
676
680
  });
677
681
  const transferInsertAfterSchema = Type.String({
678
682
  description:
679
- "Bare 4-char anchor of the destination line after which the block goes; it may live in another file than the source, and the destination file is the one this anchor belongs to. The anchor line is preserved.",
683
+ "4-char anchor of the destination line; the block goes after it and may live in another file.",
680
684
  });
681
685
  const transferPathRequiredSchema = Type.String({
682
686
  description:
683
- "Path to the source or destination file the anchors were served for; required and must match anchor ownership. The anchors still resolve both files.",
687
+ "Path to the source or destination file the anchors were served for; required and must match anchor ownership.",
684
688
  });
685
689
 
686
690
  const transferToolSchema = Type.Object(
@@ -715,7 +719,6 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
715
719
  guidelines: loadGuide(`../prompts/${kind}-guidelines.md`),
716
720
  },
717
721
  flags,
718
- kind,
719
722
  );
720
723
  return {
721
724
  name: kind,
@@ -764,6 +767,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
764
767
  signal,
765
768
  preloadedNorm: preload,
766
769
  served: plan.servedOverride,
770
+ endingOverrides: plan.endingOverrides,
767
771
  });
768
772
  return commitEdit(pipe, {
769
773
  path: pipe.path,
@@ -13,13 +13,17 @@ export interface EditToolFlags {
13
13
  strictInput: boolean;
14
14
  autoRead: boolean;
15
15
  autoReadAllActive: boolean;
16
+ replaceWithinEnabled: boolean;
17
+ copyMoveEnabled: boolean;
16
18
  }
17
19
 
18
20
  export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
19
21
  requirePath: false,
20
22
  strictInput: false,
21
23
  autoRead: true,
22
- autoReadAllActive: false
24
+ autoReadAllActive: false,
25
+ replaceWithinEnabled: true,
26
+ copyMoveEnabled: true
23
27
  };
24
28
 
25
29
  export async function currentEditFlags(): Promise<EditToolFlags> {
@@ -28,7 +32,9 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
28
32
  requirePath: config.requirePath === true,
29
33
  strictInput: config.strictInput === true,
30
34
  autoRead: config.autoRead !== false,
31
- autoReadAllActive: (config.autoReadAll ?? "off") !== "off"
35
+ autoReadAllActive: (config.autoReadAll ?? "off") !== "off",
36
+ replaceWithinEnabled: config.replaceWithinEnabled !== false,
37
+ copyMoveEnabled: config.copyMoveEnabled !== false
32
38
  };
33
39
  }
34
40
 
@@ -41,18 +47,18 @@ export function withReplacePrompts(base: { description: string; snippet: string;
41
47
  guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
42
48
  description = description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
43
49
  }
50
+ if (!flags.replaceWithinEnabled) {
51
+ description = description.replace(/\n?To change only part of a line without retyping the rest, use `replace_within` instead; it preserves every character the request does not name\./, "");
52
+ }
44
53
  const descriptionParts = [description];
45
54
  if (flags.requirePath) {
46
55
  descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
47
56
  snippetParts.push("; include `path` (required)");
48
- guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
49
57
  } else {
50
58
  descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
51
- guidelines.push("`replace`: path resolution is anchor-only; don't pass `path`.");
52
59
  }
53
60
  if (flags.strictInput) {
54
61
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
55
- guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
56
62
  }
57
63
  return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
58
64
  }
@@ -78,39 +84,84 @@ export function withInsertPrompts(base: { description: string; snippet: string;
78
84
  if (flags.requirePath) {
79
85
  descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
80
86
  snippetParts.push("; include `path` (required)");
81
- guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
82
87
  } else {
83
88
  descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
84
- guidelines.push("`insert`: path resolution is anchor-only; don't pass `path`.");
85
89
  }
86
90
  if (flags.strictInput) {
87
91
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
88
- guidelines.push("`insert`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
89
92
  }
90
93
  return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
91
94
  }
92
95
 
96
+ export function withReplaceWithinPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
97
+ const descriptionParts = [base.description];
98
+ const snippetParts = [base.snippet];
99
+ const guidelines = base.guidelines.map((guideline) => flags.copyMoveEnabled ? guideline : guideline.replace(", like `copy` and `move`", ""));
100
+ if (flags.requirePath) {
101
+ descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
102
+ snippetParts.push("; include `path` (required)");
103
+ } else {
104
+ descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
105
+ }
106
+ if (flags.strictInput) {
107
+ descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
108
+ }
109
+ return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
110
+ }
111
+
112
+ function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
113
+ return ops.filter((op) => {
114
+ if (op === "replace_within") return flags.replaceWithinEnabled;
115
+ if (op === "copy" || op === "move") return flags.copyMoveEnabled;
116
+ return true;
117
+ });
118
+ }
119
+
120
+ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/" }): string {
121
+ const formatted = ops.map((op) => (options?.backtick ? `\`${op}\`` : op));
122
+ if (options?.separator === "/") return formatted.join("/");
123
+ if (formatted.length <= 1) return formatted[0] ?? "";
124
+ const last = formatted[formatted.length - 1]!;
125
+ const head = formatted.slice(0, -1).join(", ");
126
+ return formatted.length === 2 ? `${head} or ${last}` : `${head}, or ${last}`;
127
+ }
128
+
129
+ export function withGrepPrompts(base: { description: string; snippet: string }, flags: EditToolFlags): { description: string; snippet: string } {
130
+ if (flags.copyMoveEnabled) return base;
131
+ return { ...base, description: base.description.replaceAll("replace, insert, copy, or move", joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags))) };
132
+ }
133
+
93
134
  export function withUndoPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
94
- if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
95
- const guidelines = base.guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
96
- return { description: base.description, snippet: base.snippet, guidelines };
135
+ const ops = gatedEditOps(["replace", "replace_within", "insert", "copy", "move"], flags);
136
+ let description = base.description;
137
+ let snippet = base.snippet;
138
+ let guidelines = [...base.guidelines];
139
+ if (!flags.autoRead) {
140
+ guidelines = guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`replace_within`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
141
+ }
142
+ if (ops.length !== 5) {
143
+ description = description.replaceAll("replace, replace_within, insert, copy, or move", joinOps(ops));
144
+ snippet = snippet.replaceAll("`replace`, `replace_within`, `insert`, `copy`, or `move`", joinOps(ops, { backtick: true }));
145
+ guidelines = guidelines.map((guideline) => guideline.replaceAll("`replace`/`replace_within`/`insert`/`copy`/`move`", joinOps(ops, { backtick: true, separator: "/" })));
146
+ }
147
+ if (!flags.copyMoveEnabled) {
148
+ guidelines = guidelines.filter((guideline) => !guideline.includes("cross-file `move`"));
149
+ }
150
+ return { description, snippet, guidelines };
97
151
  }
98
152
 
99
- export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags, toolName: "copy" | "move"): { description: string; snippet: string; guidelines: string[] } {
153
+ export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
100
154
  const descriptionParts = [base.description];
101
155
  const snippetParts = [base.snippet];
102
156
  const guidelines = [...base.guidelines];
103
157
  if (flags.requirePath) {
104
158
  descriptionParts.push("Also give `path` matching the source or destination file the anchors were served for; it is required and must match anchor ownership.");
105
159
  snippetParts.push("; include `path` (required)");
106
- guidelines.push(`\`${toolName}\`: include \`path\` matching the source or destination file the anchors were served for; it is required.`);
107
160
  } else {
108
161
  descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
109
- guidelines.push(`\`${toolName}\`: path resolution is anchor-only; don't pass \`path\`.`);
110
162
  }
111
163
  if (flags.strictInput) {
112
164
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
113
- guidelines.push(`\`${toolName}\`: strict-input is on: auto-fixable slips are rejected instead of fixed.`);
114
165
  }
115
166
  return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
116
167
  }
@@ -0,0 +1,108 @@
1
+ import { splitLines } from "./utils";
2
+ import type { DiffSpan } from "./replace-diff";
3
+
4
+ const INVISIBLE_RE = /\p{Default_Ignorable_Code_Point}/u;
5
+ const LOOKALIKE_SPACE_RE = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/u;
6
+ const LOOKALIKE_DASH_RE = /[\u2010-\u2015\u2212]/u;
7
+ const LOOKALIKE_QUOTE_RE = /[\u2018\u2019\u201c\u201d]/u;
8
+ const MAX_LISTED_CHARS = 3;
9
+
10
+ const CHAR_NAMES: Readonly<Record<number, string>> = {
11
+ 0x00a0: "no-break space",
12
+ 0x200b: "zero-width space",
13
+ 0x200c: "zero-width non-joiner",
14
+ 0x200d: "zero-width joiner",
15
+ 0x200e: "left-to-right mark",
16
+ 0x200f: "right-to-left mark",
17
+ 0x2011: "non-breaking hyphen",
18
+ 0x202f: "narrow no-break space",
19
+ 0x2060: "word joiner",
20
+ 0xfeff: "zero-width no-break space",
21
+ };
22
+
23
+ const LOOKALIKE_SUBSTITUTES: Readonly<Record<string, string>> = {
24
+ "\u00a0": " ",
25
+ "\u1680": " ",
26
+ "\u2000": " ",
27
+ "\u2001": " ",
28
+ "\u2002": " ",
29
+ "\u2003": " ",
30
+ "\u2004": " ",
31
+ "\u2005": " ",
32
+ "\u2006": " ",
33
+ "\u2007": " ",
34
+ "\u2008": " ",
35
+ "\u2009": " ",
36
+ "\u200a": " ",
37
+ "\u202f": " ",
38
+ "\u205f": " ",
39
+ "\u3000": " ",
40
+ "\u2010": "-",
41
+ "\u2011": "-",
42
+ "\u2012": "-",
43
+ "\u2013": "-",
44
+ "\u2014": "-",
45
+ "\u2015": "-",
46
+ "\u2212": "-",
47
+ "\u2018": "'",
48
+ "\u2019": "'",
49
+ "\u201c": "\"",
50
+ "\u201d": "\"",
51
+ };
52
+
53
+ export function isFidelitySensitiveChar(char: string): boolean {
54
+ return INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char);
55
+ }
56
+
57
+ function describeChar(char: string): string {
58
+ const codePoint = char.codePointAt(0)!;
59
+ const hex = `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
60
+ const name = CHAR_NAMES[codePoint];
61
+ return name === undefined ? hex : `${hex} (${name})`;
62
+ }
63
+
64
+ function unicodeLostHint(chars: string[]): string {
65
+ const listed = chars.slice(0, MAX_LISTED_CHARS).map(describeChar).join(", ");
66
+ const more = chars.length > MAX_LISTED_CHARS ? ` (+${chars.length - MAX_LISTED_CHARS} more)` : "";
67
+ return `[H_UNICODE_LOST] The removed line contained ${listed}${more}, which the replacement does not. If the request did not ask to remove it, copy the character from the served row.`;
68
+ }
69
+
70
+ function withCharsRestored(line: string, chars: readonly string[], replacementFor: (char: string) => string): string {
71
+ let out = line;
72
+ for (const char of chars) out = out.split(char).join(replacementFor(char));
73
+ return out;
74
+ }
75
+
76
+ function isDeliberateCharFix(oldLine: string, newLine: string, lostChars: readonly string[]): boolean {
77
+ if (lostChars.length === 0) return false;
78
+ if (withCharsRestored(oldLine, lostChars, (char) => LOOKALIKE_SUBSTITUTES[char] ?? "") === newLine) return true;
79
+ return withCharsRestored(oldLine, lostChars, () => "") === newLine;
80
+ }
81
+
82
+ export function fidelityHints(originalContent: string, resultContent: string, spans: readonly DiffSpan[] | undefined): string[] {
83
+ if (spans === undefined || spans.length === 0) return [];
84
+ const oldLines = splitLines(originalContent);
85
+ const newLines = splitLines(resultContent);
86
+ const lost = new Set<string>();
87
+ let offset = 0;
88
+ for (const span of [...spans].sort((a, b) => a.start - b.start)) {
89
+ const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
90
+ const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
91
+ const inserted = newLines.slice(span.start + offset, span.start + offset + span.replacementCount);
92
+ if (removed.length > 0 && inserted.length > 0) {
93
+ const insertedText = inserted.join("\n");
94
+ const dropped: string[] = [];
95
+ for (const line of removed) {
96
+ for (const char of line) {
97
+ if (!dropped.includes(char) && isFidelitySensitiveChar(char) && !insertedText.includes(char)) dropped.push(char);
98
+ }
99
+ }
100
+ const deliberateFix = removed.length === 1 && inserted.length === 1 && isDeliberateCharFix(removed[0]!, inserted[0]!, dropped);
101
+ if (!deliberateFix) {
102
+ for (const char of dropped) lost.add(char);
103
+ }
104
+ }
105
+ offset += span.replacementCount - removedCount;
106
+ }
107
+ return lost.size > 0 ? [unicodeLostHint([...lost].sort())] : [];
108
+ }
package/src/grep.ts CHANGED
@@ -11,8 +11,9 @@ 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, loadGuide } from "./prompts";
14
+ import { loadP } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
+ import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
16
17
  import { abortIf, clipLine, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
17
18
  import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
18
19
  import { serveRows } from "./served";
@@ -529,13 +530,13 @@ export function renderGrepResult(result: { content?: Array<{ type: string; text?
529
530
  if (lines.length > maxLines) shown.push(theme.fg("muted", `... ${lines.length - maxLines} more grep lines (${expandHint()})`));
530
531
  return reuseText(context, shown.join("\n"));
531
532
  }
532
- export function regGrep(pi: ExtensionAPI): void {
533
+ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
534
+ const prompted = withGrepPrompts({ description: loadP("../prompts/grep.md"), snippet: loadP("../prompts/grep-snippet.md") }, flags);
533
535
  pi.registerTool({
534
536
  name: "anchor_grep",
535
537
  label: "Anchor Grep",
536
- description: loadP("../prompts/grep.md"),
537
- promptSnippet: loadP("../prompts/grep-snippet.md"),
538
- promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
538
+ description: prompted.description,
539
+ promptSnippet: prompted.snippet,
539
540
  prepareArguments: makePrepareArguments(),
540
541
  parameters: grepToolSchema,
541
542
  executionMode: "sequential",
@@ -68,12 +68,12 @@ function resToSpan(
68
68
  const startLine = edit.hash_bounds[0].line;
69
69
  const endLine = edit.hash_bounds[1].line;
70
70
  const originalLines = fileLines.slice(startLine - 1, endLine);
71
- if (
71
+ const contentUnchanged =
72
72
  originalLines.length === edit.content_lines.length &&
73
73
  originalLines.every(
74
74
  (line, lineIndex) => line === edit.content_lines[lineIndex],
75
- )
76
- ) {
75
+ );
76
+ if (contentUnchanged && edit.content_separators === undefined) {
77
77
  return {
78
78
  kind: "noop",
79
79
  loc: edit.hash_bounds[0].hash,
@@ -82,7 +82,9 @@ function resToSpan(
82
82
  }
83
83
 
84
84
  if (edit.content_lines.length > 0) {
85
- const lastReplacementLine = edit.content_lines[edit.content_lines.length - 1]!;
85
+ const lastIndex = edit.content_lines.length - 1;
86
+ const lastReplacementLine = edit.content_lines[lastIndex]!;
87
+ const lastEnding = edit.content_separators?.[lastIndex];
86
88
  const endsWithBlank = lastReplacementLine.length === 0;
87
89
  const endsAtEofWithoutNewline =
88
90
  endLine === fileLines.length && !content.endsWith("\n");
@@ -92,7 +94,7 @@ function resToSpan(
92
94
  start: lineStarts[startLine - 1]!,
93
95
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
94
96
  replacement:
95
- endsAtEofWithoutNewline && endsWithBlank
97
+ endsAtEofWithoutNewline && (endsWithBlank || lastEnding !== undefined)
96
98
  ? `${replacement}\n`
97
99
  : replacement,
98
100
  };
@@ -19,6 +19,7 @@ export {
19
19
  parseHashRef,
20
20
  parseText,
21
21
  parseTextWithSeparators,
22
+ parsePayloadText,
22
23
  type Anchor,
23
24
  type ParsedText,
24
25
  } from "./parse";
@@ -27,6 +28,7 @@ export {
27
28
  type HEdit,
28
29
  type RHEdit,
29
30
  type HTEdit,
31
+ type HTPayloadEdit,
30
32
  type NEdit,
31
33
  resEdit,
32
34
  stripAnchorRow,
@@ -81,10 +81,14 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
81
81
  }
82
82
  const lines: string[] = [];
83
83
  const separators: (LineEnding | undefined)[] = [];
84
- for (const line of edit) {
85
- const parsed = splitWithEndings(unwrapJsonEnvelope(line));
86
- lines.push(...parsed.lines);
87
- separators.push(...parsed.endings);
84
+ for (const element of edit) {
85
+ const text = unwrapJsonEnvelope(element);
86
+ const parsed = splitWithEndings(text);
87
+ const lineCount = text.endsWith("\n") || text.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
88
+ for (let index = 0; index < lineCount; index++) {
89
+ lines.push(parsed.lines[index]!);
90
+ separators.push(parsed.endings[index]!);
91
+ }
88
92
  }
89
93
  return { lines, separators };
90
94
  }
@@ -92,3 +96,10 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
92
96
  export function parseText(edit: string[]): string[] {
93
97
  return parseTextWithSeparators(edit).lines;
94
98
  }
99
+
100
+ export function parsePayloadText(text: string): ParsedText {
101
+ if (text.length === 0) return { lines: [], separators: [] };
102
+ const parsed = splitWithEndings(text);
103
+ const lineCount = text.endsWith("\n") || text.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
104
+ return { lines: parsed.lines.slice(0, lineCount), separators: parsed.endings.slice(0, lineCount) };
105
+ }