pi-hashline-edit-pro 4.5.0 → 4.5.2

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
@@ -41,7 +41,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
41
41
  - [Configuration](#configuration)
42
42
  - [Limits](#limits)
43
43
  - [Tool result details](#tool-result-details)
44
- - [Error and warning codes](#error-and-warning-codes)
44
+ - [Error, warning, and hint codes](#error-warning-and-hint-codes)
45
45
  - [Troubleshooting](#troubleshooting)
46
46
  - [Privacy and on-disk state](#privacy-and-on-disk-state)
47
47
  - [How anchors work](#how-anchors-work)
@@ -186,7 +186,7 @@ Edge cases:
186
186
  | --- | --- |
187
187
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
188
188
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
189
- | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
189
+ | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
190
190
 
191
191
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
192
192
 
@@ -220,7 +220,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
220
220
  | --- | --- |
221
221
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
222
222
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
223
- | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is split on newlines, and stringified array text is unwrapped. |
223
+ | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A lone string is split on newlines, and stringified array text is unwrapped. |
224
224
 
225
225
  Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
226
226
 
@@ -357,7 +357,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
357
357
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
358
358
  | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
359
359
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
360
- | `requirePath` | Require path | `false` | `replace` and `insert` require a `path` argument that must match anchor ownership. |
360
+ | `requirePath` | Require path | `false` | `replace`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
361
361
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
362
362
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
363
363
 
@@ -369,7 +369,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
369
369
  | --- | --- | --- |
370
370
  | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
371
371
  | Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
372
- | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
372
+ | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
373
373
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
374
374
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
375
375
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
@@ -390,16 +390,16 @@ All seven tools return machine-readable metadata in `details` alongside the mode
390
390
  | Tool | `details` |
391
391
  | --- | --- |
392
392
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
393
- | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
393
+ | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), `hints` (informative `[H_*]` notices, for example literal escaped text written as sent), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
394
394
  | `copy`, `move` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when a move changes nothing), and `metrics` with the same counters. |
395
395
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
396
396
  | `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
397
397
 
398
- `snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`.
398
+ `snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`; hint `[H_*]` notices live in `details.hints` and are not counted.
399
399
 
400
- ## Error and warning codes
400
+ ## Error, warning, and hint codes
401
401
 
402
- Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
402
+ Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. Codes starting with `H_` are hints: the call succeeded and the file holds exactly what was requested, so the notice is informational, never blocks an edit (not even in strict-input mode), and is reported in `details.hints` instead of `details.warnings`. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
403
403
 
404
404
  Most common, with the fix:
405
405
 
@@ -422,8 +422,8 @@ Full reference:
422
422
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
423
423
  | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
424
424
  | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
425
- | `[W_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escape text such as `\u200b` or `\n`; the file receives those characters as written. Decode the escapes first if the text came from a quoted prompt. |
426
425
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
426
+ | `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
427
427
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
428
428
  | `[E_NOT_FOUND]` | The path does not exist. |
429
429
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -479,7 +479,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
479
479
 
480
480
  ### Allocation
481
481
 
482
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 26⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
482
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
483
483
 
484
484
  ### Ownership and mapping across edits
485
485
 
package/index.ts CHANGED
@@ -11,7 +11,7 @@ import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-al
11
11
  import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
12
12
  import type { RMetrics } from "./src/replace-response";
13
13
  import type { ReplaceDetails } from "./src/replace";
14
- import { extractWarnings } from "./src/replace-render";
14
+ import { extractHints, extractWarnings } from "./src/replace-render";
15
15
  import { MAX_HASH_LINES } from "./src/hashline";
16
16
  import type { AutoReadAllMode } from "./src/config";
17
17
  import {
@@ -266,6 +266,7 @@ export default function (pi: ExtensionAPI): void {
266
266
  const toolDetails = event.details as ReplaceDetails | undefined;
267
267
  const diff = toolDetails?.diff;
268
268
  const detailWarnings = Array.isArray(toolDetails?.warnings) ? toolDetails.warnings.filter((w): w is string => typeof w === "string") : [];
269
+ const detailHints = Array.isArray(toolDetails?.hints) ? toolDetails.hints.filter((h): h is string => typeof h === "string") : [];
269
270
  if (typeof diff !== "string") return;
270
271
  const hasDiff = diff.length > 0;
271
272
 
@@ -277,12 +278,14 @@ export default function (pi: ExtensionAPI): void {
277
278
  .map((entry) => entry.text)
278
279
  .join("\n");
279
280
  const warnings = detailWarnings.length ? `Warnings:\n${detailWarnings.join("\n")}` : extractWarnings(rendered);
280
- const hint = hasDiff ? (warnings ? `${diff}\n\n${warnings}` : diff) : warnings ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${warnings}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
281
+ const hints = detailHints.length ? `Hints:\n${detailHints.join("\n")}` : extractHints(rendered);
282
+ const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
283
+ const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${notices}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
281
284
  return {
282
285
  content: [
283
286
  {
284
287
  type: "text",
285
- text: hint,
288
+ text: noticeText,
286
289
  },
287
290
  ],
288
291
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.5.0",
3
+ "version": "4.5.2",
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",
package/prompts/insert.md CHANGED
@@ -1,3 +1,3 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. Inserted lines are written exactly as given; nothing else in the file changes.
1
+ Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Inserted lines are written exactly as given; nothing else in the file changes.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1,4 +1,4 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. `[]` deletes the range.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. `[]` deletes the range.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
4
 
package/src/config-ui.ts CHANGED
@@ -24,7 +24,7 @@ export function configRows(config: Config): ConfigRow[] {
24
24
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
27
- { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
27
+ { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
28
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
29
  ];
30
30
  }
package/src/grep.ts CHANGED
@@ -298,14 +298,30 @@ export async function resolveRgPath(): Promise<string> {
298
298
  throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
299
299
  }
300
300
 
301
+ async function insideGitRepo(start: string): Promise<boolean> {
302
+ let current = start;
303
+ for (;;) {
304
+ try {
305
+ await stat(join(current, ".git"));
306
+ return true;
307
+ } catch {
308
+ const parent = dirname(current);
309
+ if (parent === current) return false;
310
+ current = parent;
311
+ }
312
+ }
313
+ }
314
+
301
315
  async function collectRgMatches(
302
316
  rgPath: string,
303
317
  pattern: string,
304
318
  searchPath: string,
305
319
  req: GrepReq,
306
- signal?: AbortSignal,
320
+ signal: AbortSignal | undefined,
321
+ repoRooted: boolean,
307
322
  ): Promise<Map<string, number[]>> {
308
323
  const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
324
+ if (!repoRooted) args.push("--no-require-git");
309
325
  const wanted = req.limit ?? 100;
310
326
  args.push("--max-count", String(wanted + 1));
311
327
  if (typeof req.glob === "string" && req.glob.length > 0) {
@@ -583,7 +599,8 @@ export function regGrep(pi: ExtensionAPI): void {
583
599
  };
584
600
  const readGrepFile = makeGrepReader("real");
585
601
  const readGrepFileShadow = makeGrepReader("shadow");
586
- const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
602
+ const repoRooted = await insideGitRepo(globRoot);
603
+ const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
587
604
  const sortedFiles = [...rgMatches.keys()].sort(cmp);
588
605
  for (let f = 0; f < sortedFiles.length; f++) {
589
606
  abortIf(signal);
package/src/insert.ts CHANGED
@@ -11,7 +11,7 @@ import { stripAnchorRow } from "./hashline/resolve";
11
11
  import { withAnchorSession } from "./anchor-registry";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
14
- import { decodeStringArray, isRec, literalEscapeWarning, splitLines } from "./utils";
14
+ import { decodeStringArray, isRec, literalEscapeHint, splitLines } from "./utils";
15
15
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
16
16
  import type { RPreview, RRState } from "./replace-render";
17
17
  export { assertInsertReq, type InsertReq };
@@ -28,7 +28,7 @@ const insertDirectionSchema = Type.Union(
28
28
 
29
29
  const insertLinesSchema = Type.Array(
30
30
  Type.String({
31
- description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings; escape sequences are not decoded.",
31
+ description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
32
32
  }),
33
33
  {
34
34
  description: 'One string per line; [""] is a blank line; never include the anchor line.',
@@ -192,7 +192,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
192
192
  }
193
193
  assertInsertReq(canonical);
194
194
  const req = canonical;
195
- const literalEscape = literalEscapeWarning(req.lines, "lines");
195
+ const literalEscape = literalEscapeHint(req.lines, "lines");
196
196
  if (literalEscape !== undefined) insertWarnings.push(literalEscape);
197
197
  const targetPath = await resolveEditTargetWithRequirement({
198
198
  anchor: req.anchor,
@@ -4,7 +4,7 @@ import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./uti
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
6
6
  description:
7
- "One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings; escape sequences are not decoded.",
7
+ "One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
8
8
  }),
9
9
  {
10
10
  description:
@@ -126,7 +126,13 @@ export function getResultText(result: {
126
126
  export function extractWarnings(
127
127
  text: string | undefined,
128
128
  ): string | undefined {
129
- return text?.match(/(?:^|\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
129
+ return text?.match(/(?:^|\n)Warnings:\n[\s\S]*?(?=\n\nHints:\n|$)/)?.[0]?.trimStart();
130
+ }
131
+
132
+ export function extractHints(
133
+ text: string | undefined,
134
+ ): string | undefined {
135
+ return text?.match(/(?:^|\n)Hints:\n[\s\S]*$/)?.[0]?.trimStart();
130
136
  }
131
137
 
132
138
  export function isApplied(
@@ -153,8 +159,8 @@ export function expandHint(): string {
153
159
  function extractSummary(text: string | undefined): string | undefined {
154
160
  if (!text) return undefined;
155
161
  if (text.includes("│")) return undefined;
156
- const warningsIdx = text.indexOf("\n\nWarnings:");
157
- const summary = warningsIdx >= 0 ? text.slice(0, warningsIdx) : text;
162
+ const noticesIdx = text.search(/\n\n(?:Warnings|Hints):\n/);
163
+ const summary = noticesIdx >= 0 ? text.slice(0, noticesIdx) : text;
158
164
  return summary.length > 0 ? summary : undefined;
159
165
  }
160
166
 
@@ -190,6 +196,8 @@ export function buildAppliedText(
190
196
  }
191
197
  const warnings = details?.warnings?.length ? `Warnings:\n${details.warnings.join("\n")}` : extractWarnings(text);
192
198
  if (warnings) sections.push(warnings);
199
+ const hints = details?.hints?.length ? `Hints:\n${details.hints.join("\n")}` : extractHints(text);
200
+ if (hints) sections.push(hints);
193
201
  return sections.length > 0 ? sections.join("\n\n") : undefined;
194
202
  }
195
203
 
@@ -85,6 +85,17 @@ function warnBlock(warnings: string[] | undefined): string {
85
85
  return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
86
86
  }
87
87
 
88
+ function hintBlock(hints: string[] | undefined): string {
89
+ return hints?.length ? `\n\nHints:\n${hints.join("\n")}` : "";
90
+ }
91
+
92
+ function splitNotices(notices: string[] | undefined): { warnings: string[]; hints: string[] } {
93
+ const warnings: string[] = [];
94
+ const hints: string[] = [];
95
+ for (const notice of notices ?? []) (notice.startsWith("[H_") ? hints : warnings).push(notice);
96
+ return { warnings, hints };
97
+ }
98
+
88
99
  export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
89
100
  const {
90
101
  path,
@@ -97,13 +108,14 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
97
108
  const noopDetailsText = noopEdit
98
109
  ? `${noopNoun} for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
99
110
  : "The edit produced identical content.";
100
- const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
111
+ const { warnings: noticeWarnings, hints } = splitNotices(warnings);
112
+ const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(noticeWarnings)}${hintBlock(hints)}`;
101
113
 
102
114
  const metrics = buildMetrics({
103
115
  classification: "noop",
104
116
  editsAttempted: editMeta.editsAttempted,
105
117
  noopEditsCount: editMeta.noopEditsCount,
106
- warningsCount: warnings?.length ?? 0,
118
+ warningsCount: noticeWarnings.length,
107
119
  });
108
120
 
109
121
  return {
@@ -115,7 +127,8 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
115
127
  snapshotId,
116
128
  classification: "noop" as const,
117
129
  metrics,
118
- ...(warnings?.length ? { warnings: [...warnings] } : {}),
130
+ ...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
131
+ ...(hints.length ? { hints: [...hints] } : {}),
119
132
  },
120
133
  };
121
134
  }
@@ -126,22 +139,23 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
126
139
  const diffResult = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
127
140
  const addedLines = editMeta.addedLines;
128
141
  const removedLines = editMeta.removedLines;
129
- const warningsBlock = warnBlock(warnings);
142
+ const { warnings: noticeWarnings, hints } = splitNotices(warnings);
143
+ const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(hints)}`;
130
144
  const successPrefix = `Successfully ${verb} in ${path}.`;
131
145
  const lineSummary = addedLines > 0 || removedLines > 0
132
146
  ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
133
147
  : "";
134
148
  const text = resultLines.length === 0
135
149
  ? "File is empty. Use replace to insert content."
136
- : warningsBlock
137
- ? `${successPrefix}${lineSummary}${warningsBlock}`
150
+ : noticesBlock
151
+ ? `${successPrefix}${lineSummary}${noticesBlock}`
138
152
  : `${successPrefix}${lineSummary}`;
139
153
 
140
154
  const metrics = buildMetrics({
141
155
  classification: "applied",
142
156
  editsAttempted: editMeta.editsAttempted,
143
157
  noopEditsCount: editMeta.noopEditsCount,
144
- warningsCount: warnings?.length ?? 0,
158
+ warningsCount: noticeWarnings.length,
145
159
  firstChangedLine: editMeta.firstChangedLine,
146
160
  lastChangedLine: editMeta.lastChangedLine,
147
161
  addedLines,
@@ -160,7 +174,8 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
160
174
  snapshotId,
161
175
  metrics,
162
176
  diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
163
- ...(warnings?.length ? { warnings: [...warnings] } : {}),
177
+ ...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
178
+ ...(hints.length ? { hints: [...hints] } : {}),
164
179
  },
165
180
  };
166
181
  }
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, assertReq, normReq } from "./payload-contract";
13
- import { literalEscapeWarning, splitLines } from "./utils";
13
+ import { literalEscapeHint, splitLines } from "./utils";
14
14
  import { loadP, loadGuide } from "./prompts";
15
15
  import { type FileIdentity } from "./fs-write";
16
16
  import { applyEdit,
@@ -48,6 +48,7 @@ export type ReplaceDetails = {
48
48
  metrics?: RMetrics;
49
49
  diffLineNumbers?: (number | null)[];
50
50
  warnings?: string[];
51
+ hints?: string[];
51
52
  batch?: { id: number; size: number; last: boolean; total: number; aborted?: boolean; abortMessage?: string };
52
53
  };
53
54
 
@@ -273,7 +274,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
273
274
  const canonical = normReq(params);
274
275
  assertReq(canonical);
275
276
  const normalizedParams = canonical;
276
- const literalEscape = literalEscapeWarning(normalizedParams.replacement_lines, "replacement_lines");
277
+ const literalEscape = literalEscapeHint(normalizedParams.replacement_lines, "replacement_lines");
277
278
  const targetPath = await resolveEditTargetWithRequirement({
278
279
  removeFrom: normalizedParams.remove_from,
279
280
  removeTo: normalizedParams.remove_to,
package/src/utils.ts CHANGED
@@ -384,7 +384,7 @@ function isSurrogateEscapePair(line: string, index: number, hex: string): boolea
384
384
  return false;
385
385
  }
386
386
 
387
- export function literalEscapeWarning(lines: string[], label: string): string | undefined {
387
+ export function literalEscapeHint(lines: string[], label: string): string | undefined {
388
388
  for (const line of lines) {
389
389
  if (!line.includes("\\")) continue;
390
390
  const hasRealBreak = REAL_LINE_BREAK_RE.test(line);
@@ -396,7 +396,7 @@ export function literalEscapeWarning(lines: string[], label: string): string | u
396
396
  if (hex.toLowerCase() === "dddd") continue;
397
397
  if (isSurrogateEscapePair(line, match.index, hex)) continue;
398
398
  }
399
- return `[W_LITERAL_ESCAPE] "${label}" contains the literal escape text "${match[0]}"`;
399
+ return `[H_LITERAL_ESCAPE] "${label}" contains the literal escaped text "${match[0]}"`;
400
400
  }
401
401
  }
402
402
  return undefined;