pi-hashline-edit-pro 6.5.0 → 6.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -190,8 +190,6 @@ Edge cases:
190
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
191
191
  | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
192
192
 
193
- `replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
194
-
195
193
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
196
194
 
197
195
  ```json
@@ -221,8 +219,6 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
221
219
 
222
220
  `replace_match` changes part of a line — or the same fragment on several lines of a range — 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 searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, so the file keeps its separator, while a line break anywhere else is refused with `[E_BAD_SHAPE]` (a `match` that is only that trailing line break leaves nothing to find and is refused; in `replacement` it counts as `""` and deletes the matches). Send `match` as only the fragment that changes — never include text that `replacement` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines; if `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line change. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the lines, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
223
221
 
224
- `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
225
-
226
222
  `match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right — since `match` stays within one line, so does each replacement. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are searched as they currently stand on disk.
227
223
 
228
224
  In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `match` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.5.0",
3
+ "version": "6.5.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",
@@ -80,7 +80,6 @@ const UNDO_WRITE_CLEARS_ID = "undo-write-clears";
80
80
  const UNDO_CROSS_FILE_MOVE_ID = "undo-cross-file-move";
81
81
  const NEUTRAL_READ_REREAD = "`read`: call again after an edit when you need anchors you lack.";
82
82
  const NEUTRAL_UNDO_WRITE_CLEARS = "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write.";
83
- const GREP_OPS_PHRASE = "replace, insert, copy, or move";
84
83
  const UNDO_OPS_PHRASE = "replace, replace_match, insert, copy, or move";
85
84
  const UNDO_GUIDELINE_OPS_PHRASE = "`replace`/`replace_match`/`insert`/`copy`/`move`";
86
85
 
@@ -92,12 +91,12 @@ function batchGuideline(flags: EditToolFlags): string {
92
91
  const tools = operationNames(SHARED_EDIT_OPS, flags);
93
92
  const outcome = flags.autoRead ? "diff" : "result";
94
93
  const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
95
- return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`match\`) fails alone; a failure while the batch runs aborts it.${script}`;
94
+ return `${tools}: same-file calls in one message commit as one batch. If you already know all the edits and their anchors, send them together instead of one turn at a time; an edit that needs anchors from a previous result waits for the next turn. Earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`match\`) fails alone; a failure while the batch runs aborts it.${script}`;
96
95
  }
97
96
 
98
97
  function diffGuideline(flags: EditToolFlags): string {
99
98
  const tools = operationNames(SHARED_DIFF_OPS, flags);
100
- return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit.`;
99
+ return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit. Rows the diff does not show keep their anchors, so a follow-up edit on them needs no re-read.`;
101
100
  }
102
101
 
103
102
  function pathGuideline(flags: EditToolFlags): string {
@@ -181,9 +180,7 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
181
180
  export function withGrepPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
182
181
  const guidelines = base.guidelines.map((guideline) => guideline.text);
183
182
  if (flags.codemode) guidelines.push(GREP_SCRIPT_GUIDELINE);
184
- if (flags.copyMoveEnabled) return { description: base.description, snippet: base.snippet, guidelines };
185
- const description = base.description.replaceAll(GREP_OPS_PHRASE, joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags)));
186
- return { description, snippet: base.snippet, guidelines };
183
+ return { description: base.description, snippet: base.snippet, guidelines };
187
184
  }
188
185
 
189
186
  export function withUndoPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
@@ -396,6 +396,25 @@ export function annotateLiteralEscapeHints(
396
396
  });
397
397
  }
398
398
 
399
+ const LITERAL_ESCAPE_DETAIL_RE = /^\[H_LITERAL_ESCAPE\] [^:]*: "(.*?)" written as literal text(?: \((.*)\))?\.?$/;
400
+ const LITERAL_ESCAPE_ANCHOR_RE = /([A-Za-z]{4})│/g;
401
+ const UNICODE_LOST_DETAIL_RE = /^\[H_UNICODE_LOST\] ([A-Za-z]{4})│ missing "([^"]*)"/;
402
+
403
+ export function dedupeLiteralEscapeHints(hints: readonly string[]): string[] {
404
+ const suppressed = new Set<string>();
405
+ for (const hint of hints) {
406
+ const literal = LITERAL_ESCAPE_DETAIL_RE.exec(hint);
407
+ if (literal === null) continue;
408
+ const escape = literal[1] ?? "";
409
+ for (const anchor of (literal[2] ?? "").matchAll(LITERAL_ESCAPE_ANCHOR_RE)) suppressed.add(`${anchor[1]}\0${escape}`);
410
+ }
411
+ if (suppressed.size === 0) return [...hints];
412
+ return hints.filter((hint) => {
413
+ const lost = UNICODE_LOST_DETAIL_RE.exec(hint);
414
+ return lost === null || !suppressed.has(`${lost[1]}\0${lost[2]}`);
415
+ });
416
+ }
417
+
399
418
  function separatorLostHint(oldLines: string[], span: DiffSpan, originalHashes?: readonly string[]): string | undefined {
400
419
  if (span.replacementCount !== 0 || span.end < span.start) return undefined;
401
420
  const beforeIndex = span.start - 1;
package/src/grep.ts CHANGED
@@ -393,7 +393,7 @@ const grepToolSchema = Type.Object(
393
393
  limit: Type.Optional(
394
394
  Type.Integer({
395
395
  minimum: 1,
396
- description: "Maximum number of matched lines to return",
396
+ description: "Maximum number of matched lines to return (default 100)",
397
397
  }),
398
398
  ),
399
399
  },
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";
@@ -32,11 +32,6 @@ const insertTextSchema = Type.String({
32
32
  `The exact text to insert; an empty string inserts one blank line. ${TEXT_ENDING_NOTE} Never include the anchor line.`,
33
33
  });
34
34
 
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
35
  const insertToolSchema = Type.Object(
41
36
  {
42
37
  anchor: insertAnchorSchema,
@@ -50,7 +45,7 @@ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSc
50
45
  if (!requirePath) return insertToolSchema;
51
46
  return Type.Object(
52
47
  {
53
- path: insertPathRequiredSchema,
48
+ path: pathRequiredSchema,
54
49
  anchor: insertAnchorSchema,
55
50
  direction: insertDirectionSchema,
56
51
  text: insertTextSchema,
@@ -17,7 +17,7 @@ const removeToSchema = Type.String({
17
17
  description:
18
18
  "4-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
  });
@@ -253,16 +253,12 @@ const replaceMatchToSchema = Type.String({
253
253
  });
254
254
  const replaceMatchMatchSchema = Type.String({
255
255
  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.",
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.",
257
257
  });
258
258
  const replaceMatchReplacementSchema = Type.String({
259
259
  description:
260
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
261
  });
262
- const replaceMatchPathRequiredSchema = Type.String({
263
- description:
264
- "Path to the file the anchors were served for; required and must match anchor ownership.",
265
- });
266
262
 
267
263
  export const replaceMatchToolSchema = Type.Object(
268
264
  {
@@ -278,7 +274,7 @@ export function buildReplaceMatchToolSchema(requirePath: boolean): typeof replac
278
274
  if (!requirePath) return replaceMatchToolSchema;
279
275
  return Type.Object(
280
276
  {
281
- path: replaceMatchPathRequiredSchema,
277
+ path: pathRequiredSchema,
282
278
  replace_from: replaceMatchFromSchema,
283
279
  replace_to: replaceMatchToSchema,
284
280
  match: replaceMatchMatchSchema,
package/src/read.ts CHANGED
@@ -320,7 +320,7 @@ function buildReadToolSchema(codemode: boolean) {
320
320
  limit: Type.Optional(
321
321
  Type.Integer({
322
322
  minimum: 1,
323
- description: "Maximum number of lines to read from offset",
323
+ description: "Maximum number of lines to read from offset; counted from the anchor row when `offset` is an anchor",
324
324
  }),
325
325
  ),
326
326
  ...(codemode
@@ -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
  };
@@ -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 +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 4-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; moving a range onto itself reports `No changes made` and leaves the anchors alone. 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
3
  A cross-file move commits on its own when its source file also has batched edits in the message.
@@ -1,3 +1,3 @@
1
- Replace part of a line — or the same fragment on several lines of a range — 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 searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, while a line break anywhere else is refused; a lone line break leaves nothing to find in `match` and counts as `""` in `replacement`. Send only the fragment that changes — never text that `replacement` leaves unchanged. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide; when only one occurrence on a range should change, narrow `replace_from`/`replace_to` instead of lengthening the fragment. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. `remove_from`/`remove_to` are accepted as aliases.
1
+ Replace part of a line — or the same fragment on several lines of a range — without retyping the rest. `match` is the exact text to find inside the anchored range, and `replacement` replaces every occurrence; the text around each match is left untouched. Send only the fragment that changes — never text that `replacement` leaves unchanged — and narrow `replace_from`/`replace_to` when only one occurrence should change. `match` only needs to be unique inside the anchored range, not file-wide. For a whole-line rewrite, use `replace` with the same anchors.
2
2
 
3
3
  The two boundary anchors must still match what was last shown; lines strictly inside the range are searched as they are on disk.
@@ -1,3 +1,3 @@
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 its first and last line, `text` is the exact replacement text, and `replace_from`/`replace_to` are accepted as aliases. Deleting every line empties the file, and the result names the empty-line anchor, so a follow-up `replace` can seed content without a `read`.
1
+ Replace a range of lines (or a single line) in a text file by anchor. Deleting every line empties the file, and the result names the empty-line anchor, so a follow-up `replace` can seed content without a `read`.
2
2
 
3
3
  To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name.
@@ -1,4 +1,4 @@
1
- - `copy`/`move`: only the source's two boundary rows and the destination line need serving; to append to the end of a file, anchor that file's last line with `direction: "after"` — not a marker line and not the block's own end.
1
+ - `copy`/`move`: only the first and last marker rows and the destination line need anchors — grep the markers, the body is copied; to append to the end of a file, anchor that file's last line with `direction: "after"`.
2
2
  - `copy`/`move`: within one file the insertion point must fall before the source range's first line or after its last; anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) targets the gap right beside the block.
3
3
  - `copy`/`move`: `source_from` and `source_to` may live in a different file than `anchor`; an empty destination file is seeded with the copied or moved lines.
4
4
  - `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.