pi-hashline-edit-pro 6.5.0 → 7.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.
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  ANCHOR_LEN,
3
- HASH_CLASS,
3
+ HASH_RUN,
4
4
  } from "./hash";
5
5
  import { ALPH_RE } from "./alphabet";
6
6
  import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
7
7
  import { splitWithEndings } from "../line-endings";
8
8
  import type { LineEnding } from "../normalize";
9
9
 
10
- const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
10
+ const HASH_EXTRACT_RE = new RegExp(HASH_RUN);
11
11
 
12
12
  export type Anchor = { hash: string };
13
13
 
@@ -15,11 +15,11 @@ function diagRef(ref: string): string {
15
15
  const trimmed = ref.trim();
16
16
 
17
17
  if (!trimmed.length) {
18
- return `[E_BAD_REF] Invalid anchor. Expected a 4-character anchor (letters only, e.g. "Hasu").`;
18
+ return `[E_BAD_REF] Invalid anchor. Expected a 5-character anchor (lowercase letters, e.g. "abcde") from read or grep output.`;
19
19
  }
20
20
 
21
21
  if (/^\d+/.test(trimmed)) {
22
- return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "Hasu"): no line numbers or trailing content.`;
22
+ return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "abcde") with no line numbers or trailing content; anchors come from a read or grep row and are exactly 5 lowercase letters.`;
23
23
  }
24
24
  if (trimmed.includes("│") && trimmed.includes("\n")) {
25
25
  const lines = trimmed.split(/\r?\n/);
@@ -28,29 +28,36 @@ function diagRef(ref: string): string {
28
28
  const hashRe = HASH_EXTRACT_RE;
29
29
  const firstMatch = first.match(hashRe);
30
30
  const lastMatch = last.match(hashRe);
31
- const firstHash = firstMatch?.[0] ?? "Hasu";
32
- const lastHash = lastMatch?.[0] ?? "Hasu";
31
+ const firstHash = firstMatch?.[0] ?? "abcde";
32
+ const lastHash = lastMatch?.[0] ?? "abcde";
33
33
  const preview = first.slice(0, 60);
34
- return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-character anchor (letters only, e.g. "Hasu"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in text.`;
34
+ return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 5-character anchor (lowercase letters, e.g. "abcde"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in text.`;
35
35
  }
36
36
  if (trimmed.includes("│")) {
37
- return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-character anchor, drop everything from "│" onward.`;
37
+ return `[E_BAD_REF] Invalid anchor "${trimmed.slice(0, 60)}": use only the 5-character anchor, drop everything from "│" onward (any line number, gutter, and content).`;
38
38
  }
39
39
 
40
- return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-character anchor (letters only, e.g. "Hasu").`;
40
+ return `[E_BAD_REF] Invalid anchor "${trimmed.slice(0, 60)}". Expected a 5-character anchor (lowercase letters, e.g. "abcde") from read or grep output.`;
41
41
  }
42
42
 
43
- export function parseHashRef(ref: string): Anchor {
43
+ export function parseHashRef(ref: string, warnings?: string[], label = "anchor"): Anchor {
44
44
  const trimmed = ref.trim();
45
+ const unquoted = trimmed.length >= 2 && (trimmed[0] === '"' || trimmed[0] === "'") && trimmed[trimmed.length - 1] === trimmed[0]
46
+ ? trimmed.slice(1, -1).trim()
47
+ : trimmed;
45
48
 
46
49
  if (
47
- trimmed.length === ANCHOR_LEN &&
48
- ALPH_RE.test(trimmed)
50
+ unquoted.length === ANCHOR_LEN &&
51
+ ALPH_RE.test(unquoted)
49
52
  ) {
50
- return { hash: trimmed };
53
+ const hash = unquoted.toLowerCase();
54
+ if (hash !== unquoted) {
55
+ warnings?.push(`[W_ANCHOR_CASE] ${label} "${unquoted}" was lowercased to "${hash}".`);
56
+ }
57
+ return { hash };
51
58
  }
52
59
 
53
- throw new Error(diagRef(ref));
60
+ throw new Error(diagRef(unquoted));
54
61
  }
55
62
 
56
63
  const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
@@ -130,17 +130,17 @@ const ITEM_KS = new Set(["text", "remove_from", "remove_to"]);
130
130
  function assertBounds(edit: Record<string, unknown>): void {
131
131
  if ("remove_from" in edit && typeof edit.remove_from !== "string") {
132
132
  throw new Error(
133
- `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
133
+ `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (5-char anchor).`,
134
134
  );
135
135
  }
136
136
  if ("remove_to" in edit && typeof edit.remove_to !== "string") {
137
137
  throw new Error(
138
- `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
138
+ `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (5-char anchor).`,
139
139
  );
140
140
  }
141
141
  if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
142
142
  throw new Error(
143
- `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
143
+ `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (5-char anchors from read output).`,
144
144
  );
145
145
  }
146
146
  }
@@ -175,18 +175,32 @@ function resolveParsedEdit(removeFrom: string, removeTo: string, parsed: ParsedT
175
175
  }) as [string, string];
176
176
  return {
177
177
  content_lines: replaceLines,
178
- hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
178
+ hash_bounds: [parseHashRef(bounds[0], warnings, "remove_from"), parseHashRef(bounds[1], warnings, "remove_to")],
179
179
  ...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
180
180
  };
181
181
  }
182
182
 
183
183
  export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
184
184
 
185
+ const NUMBERED_ANCHOR_ROW_RE = new RegExp(`^\\s*\\d+\\s*[:-]?\\s*│\\s*(${HASH_RUN})│`);
186
+
187
+ const PAREN_LINE_SUFFIX_RE = new RegExp(`^(${HASH_RUN})\\s*\\(\\s*(?:line|lines)\\s+\\d+(?:\\s*[-–—]\\s*\\d+)?\\s*\\)$`, "i");
188
+
185
189
  export function stripAnchorRow(
186
190
  trimmed: string,
187
191
  entryLabel: string,
188
192
  warnings?: string[],
189
193
  ): string {
194
+ const numbered = trimmed.match(NUMBERED_ANCHOR_ROW_RE);
195
+ if (numbered) {
196
+ warnings?.push(`[W_BAD_REF] Stripped line-numbered row from ${entryLabel} "${clipLine(trimmed, 48)}".`);
197
+ return numbered[1]!;
198
+ }
199
+ const paren = trimmed.match(PAREN_LINE_SUFFIX_RE);
200
+ if (paren) {
201
+ warnings?.push(`[W_BAD_REF] Stripped "(line N)" suffix from ${entryLabel} "${clipLine(trimmed, 48)}".`);
202
+ return paren[1]!;
203
+ }
190
204
  const match = trimmed.match(ANCHOR_ROW_RE);
191
205
  if (!match) return trimmed;
192
206
  const marker =
@@ -435,17 +449,17 @@ export function assertRangeServed(
435
449
  rows.push(fmtRow(hash, clipLine(content)));
436
450
  }
437
451
  const location = filePath ? ` in ${filePath}` : "";
452
+ const rangeDescription = `the replaced range (lines ${startLine}-${endLine})${location}`;
438
453
  const first = mismatchLines[0]!;
439
- const mismatchText =
440
- mismatchLines.length === 1
441
- ? `Line ${first} of the replaced range (lines ${startLine}-${endLine})${location} does not match`
442
- : `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
454
+ const mismatchText = mismatchLines.length === 1
455
+ ? `Line ${first} of ${rangeDescription} was never shown or changed on disk`
456
+ : `${mismatchLines.length} of ${rangeLength} line(s) in ${rangeDescription} were never shown or changed on disk`;
443
457
  const capHint =
444
458
  rangeLength > shownLength
445
459
  ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
446
460
  : "\n\nRetry with the fresh anchors above without a read.";
447
461
  const message =
448
- `[E_RANGE_STALE] ${mismatchText} what was shown. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
462
+ `[E_RANGE_STALE] ${mismatchText}. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
449
463
  throw new RangeStaleError(message, shownHashes, shownMap);
450
464
  }
451
465
 
package/src/insert.ts CHANGED
@@ -11,7 +11,7 @@ import type { LineEnding } from "./normalize";
11
11
  import { stripAnchorRow } from "./hashline/resolve";
12
12
  import { withAnchorSession } from "./anchor-registry";
13
13
  import { loadP, loadGuidelines } from "./prompts";
14
- import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
14
+ import { assertInsertReq, normReq, pathRequiredSchema, type InsertReq } from "./payload-contract";
15
15
  import { anchorCarryIndex, isRec, literalEscapeHints, splitLines } from "./utils";
16
16
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
17
17
  import type { RPreview, RRState } from "./replace-render";
@@ -20,23 +20,19 @@ export { assertInsertReq, type InsertReq };
20
20
 
21
21
  const insertAnchorSchema = Type.String({
22
22
  description:
23
- "4-char anchor of the line to insert next to.",
23
+ "5-char anchor of the line to insert next to.",
24
24
  });
25
25
 
26
- const insertDirectionSchema = Type.Union(
27
- [Type.Literal("after"), Type.Literal("before")],
28
- { description: '"after" or "before"' },
29
- );
26
+ const insertDirectionSchema = Type.Unsafe<"after" | "before">({
27
+ type: "string",
28
+ enum: ["after", "before"],
29
+ description: '"after" or "before"',
30
+ });
30
31
  const insertTextSchema = Type.String({
31
32
  description:
32
33
  `The exact text to insert; an empty string inserts one blank line. ${TEXT_ENDING_NOTE} Never include the anchor line.`,
33
34
  });
34
35
 
35
- const insertPathRequiredSchema = Type.String({
36
- description:
37
- "Path to the file the anchor was served for; required and must match anchor ownership.",
38
- });
39
-
40
36
  const insertToolSchema = Type.Object(
41
37
  {
42
38
  anchor: insertAnchorSchema,
@@ -50,7 +46,7 @@ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSc
50
46
  if (!requirePath) return insertToolSchema;
51
47
  return Type.Object(
52
48
  {
53
- path: insertPathRequiredSchema,
49
+ path: pathRequiredSchema,
54
50
  anchor: insertAnchorSchema,
55
51
  direction: insertDirectionSchema,
56
52
  text: insertTextSchema,
@@ -63,7 +59,7 @@ export function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[
63
59
  const trimmedAnchor = raw.trim();
64
60
  const warnings: string[] = [];
65
61
  const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
66
- return { ref: parseHashRef(anchorText), warnings };
62
+ return { ref: parseHashRef(anchorText, warnings, "anchor"), warnings };
67
63
  }
68
64
 
69
65
  export function buildInsertEdit(
@@ -10,14 +10,14 @@ const textSchema = Type.String({
10
10
 
11
11
  const removeFromSchema = Type.String({
12
12
  description:
13
- "4-char anchor of the FIRST line to remove.",
13
+ "5-char anchor of the FIRST line to remove.",
14
14
  });
15
15
 
16
16
  const removeToSchema = Type.String({
17
17
  description:
18
- "4-char anchor of the LAST line to remove.",
18
+ "5-char anchor of the LAST line to remove.",
19
19
  });
20
- const pathRequiredSchema = Type.String({
20
+ export const pathRequiredSchema = Type.String({
21
21
  description:
22
22
  "Path to the file the anchors were served for; required and must match anchor ownership.",
23
23
  });
@@ -59,11 +59,19 @@ export type RawReqParams = {
59
59
  };
60
60
 
61
61
  const ROOT_KS = new Set(["path", "remove_from", "remove_to", "text"]);
62
+
63
+ const REPLACE_MATCH_SHAPE_KEYS = ["match", "replacement", "old_string", "new_string"];
64
+
65
+ function replaceMatchFieldHint(request: Record<string, unknown>): string | undefined {
66
+ if (!REPLACE_MATCH_SHAPE_KEYS.some((key) => key in request)) return undefined;
67
+ return 'This looks like a replace_match request: to change part of a line, call replace_match with "replace_from", "replace_to", "match", and "replacement"; replace takes "remove_from", "remove_to", and "text".';
68
+ }
69
+
62
70
  export function assertReq(request: unknown): asserts request is ReqParams {
63
71
  if (!isRec(request)) {
64
72
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
65
73
  }
66
- rejectUnknownFields(request, ROOT_KS, "Edit request");
74
+ rejectUnknownFields(request, ROOT_KS, "Edit request", replaceMatchFieldHint(request));
67
75
  if (request.path !== undefined && typeof request.path !== "string") {
68
76
  throw new Error('[E_BAD_SHAPE] Edit request field "path" must be a string when provided.');
69
77
  }
@@ -124,7 +132,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
124
132
  throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
125
133
  }
126
134
  if (typeof request.anchor !== "string" || request.anchor.length === 0) {
127
- throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
135
+ throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (5-char anchor from read output).');
128
136
  }
129
137
  if (request.direction !== "before" && request.direction !== "after") {
130
138
  throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
@@ -154,13 +162,13 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
154
162
  throw new Error('[E_BAD_SHAPE] Copy/move request field "path" must be a string when provided.');
155
163
  }
156
164
  if (typeof request.source_from !== "string" || request.source_from.length === 0) {
157
- throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_from" string (4-char anchor from read output).');
165
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_from" string (5-char anchor from read output).');
158
166
  }
159
167
  if (typeof request.source_to !== "string" || request.source_to.length === 0) {
160
- throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (4-char anchor from read output).');
168
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (5-char anchor from read output).');
161
169
  }
162
170
  if (typeof request.anchor !== "string" || request.anchor.length === 0) {
163
- throw new Error('[E_BAD_SHAPE] Copy/move request requires an "anchor" string (4-char anchor from read output).');
171
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires an "anchor" string (5-char anchor from read output).');
164
172
  }
165
173
  if (request.direction !== "before" && request.direction !== "after") {
166
174
  throw new Error('[E_BAD_SHAPE] Copy/move request "direction" must be "before" or "after" (the anchor is the destination line).');
@@ -177,22 +185,27 @@ export interface ReplaceMatchReq {
177
185
  replacement: string;
178
186
  }
179
187
 
180
- export function singleLineFragment(field: "match" | "replacement", raw: string): string {
188
+ export function singleLineMatch(raw: string): string {
181
189
  const text = toLF(raw);
182
190
  const fragment = text.endsWith("\n") ? text.slice(0, -1) : text;
183
191
  const lineBreak = fragment.indexOf("\n");
184
192
  if (lineBreak >= 0) {
185
193
  const column = [...fragment.slice(0, lineBreak)].length + 1;
186
194
  throw new Error(
187
- `[E_BAD_SHAPE] ${field} must stay within one line, but the value contains a line break at column ${column}. A trailing line break is accepted as the line's own terminator; text on another line belongs to replace.`,
195
+ `[E_BAD_SHAPE] match must stay within one line, but the value contains a line break at column ${column}. A trailing line break is accepted as the line's own terminator; a match that spans lines belongs to replace.`,
188
196
  );
189
197
  }
190
- if (field === "match" && fragment.length === 0) {
198
+ if (fragment.length === 0) {
191
199
  throw new Error('[E_BAD_SHAPE] Replace-match request field "match" must be a non-empty string holding the exact text to find.');
192
200
  }
193
201
  return fragment;
194
202
  }
195
203
 
204
+ export function replacementFragment(raw: string): string {
205
+ const text = toLF(raw);
206
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
207
+ }
208
+
196
209
  export function assertReplaceMatchReq(request: unknown): asserts request is ReplaceMatchReq {
197
210
  if (!isRec(request)) {
198
211
  throw new Error("[E_BAD_SHAPE] Replace-match request must be an object.");
@@ -213,8 +226,7 @@ export function assertReplaceMatchReq(request: unknown): asserts request is Repl
213
226
  if (within.replace_from.length === 0 || within.replace_to.length === 0) {
214
227
  throw new Error('[E_BAD_SHAPE] Replace-match request requires non-empty "replace_from" and "replace_to" anchors.');
215
228
  }
216
- singleLineFragment("match", within.match);
217
- singleLineFragment("replacement", within.replacement);
229
+ singleLineMatch(within.match);
218
230
  assertNoNul([within.replacement]);
219
231
  }
220
232
 
@@ -245,23 +257,19 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
245
257
 
246
258
  const replaceMatchFromSchema = Type.String({
247
259
  description:
248
- "4-char anchor of the FIRST line of the range searched.",
260
+ "5-char anchor of the FIRST line of the range searched.",
249
261
  });
250
262
  const replaceMatchToSchema = Type.String({
251
263
  description:
252
- "4-char anchor of the LAST line of the range searched; same as `replace_from` for a single line.",
264
+ "5-char anchor of the LAST line of the range searched; same as `replace_from` for a single line.",
253
265
  });
254
266
  const replaceMatchMatchSchema = Type.String({
255
267
  description:
256
- "The exact text to find inside the selected lines, copied from the served row: only the fragment that changes, never text that `replacement` leaves unchanged. It must stay within one line and hold text: a single trailing line break is accepted as the line's own terminator, a line break anywhere else is refused, and a value that is only a line break leaves nothing to find and is refused. Every occurrence inside the range is replaced; the text around each match is left untouched. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line match: use `replace` instead.",
268
+ "The exact text to find inside the selected lines, copied from the served row: only the fragment that changes, never text that `replacement` leaves unchanged. It must stay within one line and hold text: a single trailing line break is accepted as the line's own terminator, a line break anywhere else is refused, and a value that is only a line break leaves nothing to find and is refused. Every occurrence inside the range is replaced; the text around each match is left untouched.",
257
269
  });
258
270
  const replaceMatchReplacementSchema = Type.String({
259
271
  description:
260
- `The exact replacement for every matched occurrence within the range. "" deletes the matches. It must stay within one line: a single trailing line break is accepted and dropped (a value that is only a line break counts as ""), and a line break anywhere else is refused. The rest of each line is kept byte-for-byte.`,
261
- });
262
- const replaceMatchPathRequiredSchema = Type.String({
263
- description:
264
- "Path to the file the anchors were served for; required and must match anchor ownership.",
272
+ `The exact replacement for every matched occurrence within the range. "" deletes the matches. A single trailing line break is accepted and dropped (a value that is only a line break counts as ""); an interior or leading line break splits the replacement into lines, and new lines inherit the file's line endings. The text around each match is kept byte-for-byte.`,
265
273
  });
266
274
 
267
275
  export const replaceMatchToolSchema = Type.Object(
@@ -278,7 +286,7 @@ export function buildReplaceMatchToolSchema(requirePath: boolean): typeof replac
278
286
  if (!requirePath) return replaceMatchToolSchema;
279
287
  return Type.Object(
280
288
  {
281
- path: replaceMatchPathRequiredSchema,
289
+ path: pathRequiredSchema,
282
290
  replace_from: replaceMatchFromSchema,
283
291
  replace_to: replaceMatchToSchema,
284
292
  match: replaceMatchMatchSchema,
package/src/prompts.ts CHANGED
@@ -14,7 +14,7 @@ const GUIDELINE_IDS_BY_NAME: Record<string, readonly string[]> = {
14
14
  "copy-guidelines.md": ["copy-single-anchor"],
15
15
  "move-guidelines.md": ["move-same-place"],
16
16
  "transfer-guidelines.md": ["transfer-boundary-serving", "transfer-same-file", "transfer-cross-file", "transfer-batch-move"],
17
- "grep-guidelines.md": ["grep-literal", "grep-row-markers"],
17
+ "grep-guidelines.md": ["grep-literal"],
18
18
  "undo-last-change-guidelines.md": ["undo-single-level", "undo-write-clears", "undo-cross-file-move"],
19
19
  };
20
20
 
package/src/read.ts CHANGED
@@ -17,9 +17,9 @@ import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES, parseHashRef,
17
17
  import { toCwd } from "./paths";
18
18
  import { abortIf, isRec, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
19
19
  import { loadP, loadGuidelines, type Guideline } from "./prompts";
20
- import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
20
+ import { withReadPrompts, DEFAULT_EDIT_FLAGS, staleAnchorMessage, type EditToolFlags } from "./edit-common";
21
21
  import { valAccess } from "./validation";
22
- import { withAnchorSession, adoptAnchors, ownerOf, ownersDifferingOnlyByCase, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
22
+ import { withAnchorSession, adoptAnchors, ownerOf, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
23
23
  import { serveRows } from "./served";
24
24
  import { snapshotCache } from "./hash-store/cache";
25
25
  import { Text } from "@earendil-works/pi-tui";
@@ -208,9 +208,7 @@ export function resolveReadOffset(
208
208
  const ref = parseHashRef(offset);
209
209
  const owner = ownerOf(ref.hash);
210
210
  if (owner === undefined) {
211
- const folded = ownersDifferingOnlyByCase(ref.hash);
212
- const hint = folded.length > 0 ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.` : "";
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.`);
211
+ throw new Error(staleAnchorMessage(ref.hash, "Use a line number, or read again for fresh anchors."));
214
212
  }
215
213
  if (owner.path !== resolvedPath) {
216
214
  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.`);
@@ -320,7 +318,7 @@ function buildReadToolSchema(codemode: boolean) {
320
318
  limit: Type.Optional(
321
319
  Type.Integer({
322
320
  minimum: 1,
323
- description: "Maximum number of lines to read from offset",
321
+ description: "Maximum number of lines to read from offset; counted from the anchor row when `offset` is an anchor",
324
322
  }),
325
323
  ),
326
324
  ...(codemode
@@ -114,7 +114,7 @@ export function disambiguateDuplicateAnchors(diff: string): string {
114
114
  if (!match || match[1] !== "-") return line;
115
115
  if (!live.has(match[2]!)) return line;
116
116
  changed = true;
117
- return `- │${line.slice(match[0].length)}`;
117
+ return `-${" ".repeat(ANCHOR_LEN)}│${line.slice(match[0].length)}`;
118
118
  });
119
119
  return changed ? out.join("\n") : diff;
120
120
  }
@@ -12,7 +12,8 @@ import {
12
12
  buildReplaceMatchToolSchema,
13
13
  getReplaceMatchInput,
14
14
  normReq,
15
- singleLineFragment,
15
+ replacementFragment,
16
+ singleLineMatch,
16
17
  type ReplaceMatchReq,
17
18
  } from "./payload-contract";
18
19
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
@@ -46,26 +47,6 @@ export interface MatchPlan {
46
47
  servedOverride?: ReadonlyMap<string, string>;
47
48
  }
48
49
 
49
- function replaceAllOccurrences(
50
- line: string,
51
- matchText: string,
52
- replacementText: string,
53
- ): { text: string; count: number } {
54
- const parts: string[] = [];
55
- let cursor = 0;
56
- let count = 0;
57
- for (;;) {
58
- const found = line.indexOf(matchText, cursor);
59
- if (found < 0) break;
60
- parts.push(line.slice(cursor, found), replacementText);
61
- cursor = found + matchText.length;
62
- count += 1;
63
- }
64
- if (count === 0) return { text: line, count: 0 };
65
- parts.push(line.slice(cursor));
66
- return { text: parts.join(""), count };
67
- }
68
-
69
50
  function linesToPayloadText(lines: string[]): string {
70
51
  if (lines.length === 0) return "";
71
52
  const joined = lines.join("\n");
@@ -143,7 +124,33 @@ export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warn
143
124
  const warnings: string[] = [];
144
125
  const from = stripAnchorRow(req.replace_from.trim(), "replace_from entry", warnings);
145
126
  const to = stripAnchorRow(req.replace_to.trim(), "replace_to entry", warnings);
146
- return { refs: { from: parseHashRef(from), to: parseHashRef(to) }, warnings };
127
+ return { refs: { from: parseHashRef(from, warnings, "replace_from"), to: parseHashRef(to, warnings, "replace_to") }, warnings };
128
+ }
129
+
130
+ function replacementLinesFor(raw: string): string[] {
131
+ const fragment = replacementFragment(raw);
132
+ return fragment.length === 0 ? [""] : fragment.split("\n");
133
+ }
134
+
135
+ function replaceInLine(
136
+ line: string,
137
+ matchText: string,
138
+ replacementLines: readonly string[],
139
+ ): { lines: string[]; count: number } {
140
+ const lines: string[] = [""];
141
+ let cursor = 0;
142
+ let count = 0;
143
+ for (;;) {
144
+ const found = line.indexOf(matchText, cursor);
145
+ if (found < 0) break;
146
+ lines[lines.length - 1] += line.slice(cursor, found) + replacementLines[0]!;
147
+ for (let index = 1; index < replacementLines.length; index++) lines.push(replacementLines[index]!);
148
+ cursor = found + matchText.length;
149
+ count += 1;
150
+ }
151
+ if (count === 0) return { lines: [line], count: 0 };
152
+ lines[lines.length - 1] += line.slice(cursor);
153
+ return { lines, count };
147
154
  }
148
155
 
149
156
  export function buildReplaceMatchEdit(
@@ -153,8 +160,8 @@ export function buildReplaceMatchEdit(
153
160
  displayPath: string,
154
161
  served?: ReadonlyMap<string, string>,
155
162
  ): MatchPlan {
156
- const matchText = singleLineFragment("match", req.match);
157
- const replacementText = singleLineFragment("replacement", req.replacement);
163
+ const matchText = singleLineMatch(req.match);
164
+ const fragmentLines = replacementLinesFor(req.replacement);
158
165
  const fileLines = splitLines(preload.normalized);
159
166
  const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
160
167
  const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
@@ -163,11 +170,12 @@ export function buildReplaceMatchEdit(
163
170
  assertBoundaryLinesServed(fileLines, preload.fileHashes, served, start, end, displayPath);
164
171
  const rangeLines = fileLines.slice(start - 1, end);
165
172
  let count = 0;
166
- const replacementLines = rangeLines.map((line) => {
167
- const result = replaceAllOccurrences(line, matchText, replacementText);
173
+ const replacementLines: string[] = [];
174
+ for (const line of rangeLines) {
175
+ const result = replaceInLine(line, matchText, fragmentLines);
168
176
  count += result.count;
169
- return result.text;
170
- });
177
+ replacementLines.push(...result.lines);
178
+ }
171
179
  if (count === 0) {
172
180
  throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, linesToPayloadText(rangeLines), matchText));
173
181
  }
@@ -2,7 +2,7 @@ import { HASH_SEP, type NEdit } from "./hashline";
2
2
  import type { ReplaceDetails } from "./replace";
3
3
  import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
4
4
  import { visLines, clipLine } from "./utils";
5
- import { annotateLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
5
+ import { annotateLiteralEscapeHints, dedupeLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
6
6
  import { anchoredLine, anchoredLinesFromDiff, diffAnchorsOmitted, toEditVerb, type EditStructured, type EditVerb, type Json } from "./structured";
7
7
 
8
8
  export type TResult = {
@@ -164,7 +164,8 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
164
164
  });
165
165
  const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
166
166
  const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
167
- const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(annotatedHints)}`;
167
+ const dedupedHints = dedupeLiteralEscapeHints(annotatedHints);
168
+ const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(dedupedHints)}`;
168
169
  const successPrefix = `Successfully ${verb} in ${path}.`;
169
170
  const lineSummary = addedLines > 0 || removedLines > 0
170
171
  ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
@@ -203,7 +204,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
203
204
  text,
204
205
  diff: diffResult.diff,
205
206
  warnings: [...noticeWarnings],
206
- hints: [...annotatedHints],
207
+ hints: [...dedupedHints],
207
208
  firstChangedLine: editMeta.firstChangedLine ?? diffResult.firstChangedLine ?? null,
208
209
  anchors,
209
210
  anchorsOmitted,
@@ -220,7 +221,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
220
221
  metrics,
221
222
  diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
222
223
  ...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
223
- ...(annotatedHints.length ? { hints: [...annotatedHints] } : {}),
224
+ ...(dedupedHints.length ? { hints: [...dedupedHints] } : {}),
224
225
  },
225
226
  structuredContent,
226
227
  };
package/src/replace.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "./replace-diff";
11
11
  import { readNormFile, type NormFile } from "./file-reader";
12
12
  import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
13
- import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
13
+ import { isRec, literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
14
14
  import { editResultSchema, withStructuredErrors } from "./structured";
15
15
  import { loadP, loadGuidelines } from "./prompts";
16
16
  import { type FileIdentity } from "./fs-write";
@@ -293,6 +293,16 @@ type ToolDef = ToolDefinition<
293
293
  RRState
294
294
  > & { renderShell?: "default" | "self" };
295
295
 
296
+ const prepareRemoveArguments = makePrepareArguments("remove");
297
+
298
+ function prepareReplaceArguments(args: unknown): unknown {
299
+ const prepared = prepareRemoveArguments(args);
300
+ if (isRec(prepared) && typeof prepared.text !== "string" && ("match" in prepared || "replacement" in prepared)) {
301
+ throw new Error('[E_BAD_SHAPE] Replace request received "match"/"replacement" instead of "remove_from"/"remove_to"/"text". To change part of a line, call replace_match with "replace_from", "replace_to", "match", and "replacement".');
302
+ }
303
+ return prepared;
304
+ }
305
+
296
306
  export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef {
297
307
  const prompted = withReplacePrompts({
298
308
  description: loadP("../tool-prompts/replace.md"),
@@ -309,7 +319,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
309
319
  promptSnippet: prompted.snippet,
310
320
  promptGuidelines: prompted.guidelines,
311
321
  ...editToolBase,
312
- prepareArguments: makePrepareArguments("remove"),
322
+ prepareArguments: prepareReplaceArguments,
313
323
  renderCall: editRenderCallWrapper(compPreview),
314
324
  renderResult: editRenderResultWrapper,
315
325
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
package/src/write-hook.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { HASH_CLASS } from "./hashline/alphabet";
2
+ import { HASH_RUN } from "./hashline/alphabet";
3
3
  import { HASH_SEP } from "./hashline/hash";
4
4
  import { sessionContextReadable, servedForPath, withAnchorSession, type AnchorSessionContext } from "./anchor-registry";
5
5
  import { resolveInCwd } from "./fs-write";
6
6
  import { abortIf, splitLines, isRec } from "./utils";
7
7
  import type { ModelLike } from "./model-gate";
8
8
 
9
- const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+(?: │ |[:-]│ ))?[+ -]?(${HASH_CLASS})${HASH_SEP}`);
9
+ const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+(?: │ |[:-]│ ))?[+ -]?(${HASH_RUN})${HASH_SEP}`);
10
10
 
11
11
  function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
12
12
  for (let i = 0; i < lines.length; i++) {
13
13
  const match = HASH_ECHO_RE.exec(lines[i]!);
14
- if (match && served.has(match[1]! as never)) return { line: i + 1, hash: match[1]! };
14
+ const hash = match?.[1]?.toLowerCase();
15
+ if (hash !== undefined && served.has(hash)) return { line: i + 1, hash };
15
16
  }
16
17
  return undefined;
17
18
  }
@@ -1 +1 @@
1
- - `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
1
+ - `copy`: the same anchor in `source_from` and `source_to` copies one line.
@@ -1,3 +1,3 @@
1
- Duplicate a range of lines at another anchor, in this or another file. The source rows and anchors stay live; the copied lines get fresh anchors in the destination's post-edit diff and keep their source line endings.
1
+ Duplicate a range of lines at another anchor. The source rows and anchors stay live; the copied lines get fresh anchors in the destination's post-edit diff and keep their source line endings.
2
2
 
3
3
  A copy 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,2 +1 @@
1
- - `grep`: set `literal: true` when the pattern contains regex metacharacters.
2
- - `grep`: `N:│` rows are matches and `N-│` rows are context;
1
+ - `grep`: set `literal: true` to match the text exactly, when characters such as `(`, `[`, `.`, or `*` in it must not act as regex.
@@ -1 +1 @@
1
- Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, 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` for exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows. Rust regex syntax (no backreferences or look-around) through a linear-time engine. 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` or narrow `path`.
1
+ Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, editable with replace, insert, copy, or move without a new read. A row returned by `grep` is already a served row, so you do not need to `read` the file to edit that line. The `=== path ===` header and line numbers locate the match. Patterns use ripgrep's Rust regex syntax (no backreferences or look-around) through a linear-time engine. 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 output is cut by the match limit, raise `limit`; when cut by rows or bytes, refine `pattern` or narrow `path`.
@@ -1 +1 @@
1
- Insert text after or before one existing line, addressed by `anchor` (a bare anchor from any served `anchor│content` row). `direction` places `text` after or before the anchor line; `text` is the exact text to insert.
1
+ Insert text next to an existing line without removing anything. The anchor line stays in place, and `anchor` is a bare 5-character anchor from any served `anchor│content` row.
@@ -1,3 +1,3 @@
1
- Relocate a range of lines to another anchor, in this or another file. Within one file the destination must sit outside the source range; moving a range onto itself reports `No changes made` and leaves the anchors alone.
1
+ Relocate a range of lines to another anchor. Within one file the destination must sit outside the source range. Rows between the source and the destination keep their content but may be re-anchored, so read that span again if you plan to edit it.
2
2
 
3
- A cross-file move commits on its own when its source file also has batched edits in the message.
3
+ A cross-file move whose source file also gets other edits in the same message runs outside any batch: same-message edits to its destination file can then either abort or make the move fail, so send that move in a separate message.