pi-hashline-edit-pro 4.5.0 → 4.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
@@ -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)
@@ -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 the literal escaped 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. |
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. |
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.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",
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. 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. `[]` 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/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;