pi-hashline-edit-pro 3.0.3 → 3.0.4

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
@@ -99,8 +99,8 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
99
99
  Notes:
100
100
 
101
101
  - The request is checked before any file I/O, so a bad request never touches the file.
102
- - Common copy-paste slips are fixed automatically and reported: a leftover `anchor│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file. The whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they identify known files in the hash store (reported as a warning); when the anchors match multiple known files the most recently touched file is picked, with the candidates named. `file_path` works as an alias for `path` in all five tools.
103
- - An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally. The duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `anchor│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
102
+ - Common copy-paste slips are fixed automatically and reported: a leftover `anchor│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file. The whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they identify known files in the hash store (reported as a warning); when the anchors match multiple known files the most recently touched file is picked, with up to 3 candidate paths named. `file_path` works as an alias for `path` in all five tools, and `replace_from`/`replace_to` work as aliases for `remove_from`/`remove_to` in `replace`.
103
+ - An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally. The duplicated lines are kept, and the result carries a `[W_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `anchor│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
104
104
  - Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, the auto-read block after `write`, the `+anchor│`/` anchor│` rows of post-edit diffs (replace, insert, and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback) and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
105
105
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading. The diff is capped at 50KB: a row longer than 50KB is shown as a marker that keeps the row's anchor (so the line stays editable via the diff), and when the total cap is hit the diff ends with a truncation note. Only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch` (which is flagged with `details.patchTruncated` when it was cut and can no longer be applied as-is).
106
106
  - Do not issue multiple replace or insert calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
@@ -127,7 +127,7 @@ Notes:
127
127
 
128
128
  Notes:
129
129
 
130
- - A missing `path` is resolved from the anchor the same way as `replace` (reported as a warning); when the anchor matches multiple known files the most recently touched file is picked, with the candidates named.
130
+ - A missing `path` is resolved from the anchor the same way as `replace` (reported as a warning); when the anchor matches multiple known files the most recently touched file is picked, with up to 3 candidate paths named.
131
131
  - The anchor line must have been shown to you (read output, a post-edit diff row, anchor_grep output, or stale-range feedback). The same verification as `replace` applies: a stale or unshown anchor is rejected with `[E_STALE_ANCHOR]`, `[E_AMBIGUOUS_ANCHOR]`, or `[E_RANGE_STALE]` and the retry needs no `read`.
132
132
  - Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`.
133
133
  - To seed an empty file, read it and insert after the `anchor│` empty-line row.
@@ -224,17 +224,21 @@ Two guarantees make this safe even with duplicated content:
224
224
 
225
225
  A no-op replace never changes the file, so anchors remain valid. On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`.
226
226
 
227
- ## Error codes
227
+ ## Error and warning codes
228
+
229
+ Codes starting with `E_` are errors (the operation failed); codes starting with `W_` are warnings (the operation succeeded with a notice).
228
230
 
229
231
  | Code | Meaning |
230
232
  | --- | --- |
231
233
  | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
234
+ | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax, embedded newlines split into lines, or a missing `path` resolved from anchors). |
232
235
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
236
+ | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
233
237
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
234
238
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
235
- | `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
236
- | `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix (the anchor plus the separator). The prefix is stripped automatically with a warning. |
237
- | `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
239
+ | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
240
+ | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix (the anchor plus the separator). The prefix is stripped automatically with a warning. |
241
+ | `[W_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
238
242
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
239
243
  | `[E_NOT_FOUND]` | The path does not exist. |
240
244
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -242,7 +246,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
242
246
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
243
247
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
244
248
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
245
- | `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
249
+ | `[W_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
246
250
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 257,795-line hashline limit or the 100MB size limit. |
247
251
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
248
252
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "3.0.3",
3
+ "version": "3.0.4",
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/src/hash-store.ts CHANGED
@@ -636,21 +636,20 @@ export function pathActivity(store: HashStore, path: string): number {
636
636
  return activity;
637
637
  }
638
638
 
639
- export function pickRecentPath(store: HashStore, candidates: string[]): string {
640
- let best = candidates[0]!;
641
- let bestSession = -1;
642
- let bestActivity = -1;
643
- for (const candidate of candidates) {
639
+ export function rankRecentPaths(store: HashStore, candidates: string[]): string[] {
640
+ const scored = candidates.map((candidate) => {
644
641
  const rank = sessionRank(candidate) ?? -1;
645
- let activity = -1;
646
- if (rank < 0) {
647
- activity = pathActivity(store, candidate);
648
- }
649
- if (rank > bestSession || (rank === bestSession && activity > bestActivity)) {
650
- best = candidate;
651
- bestSession = rank;
652
- bestActivity = activity;
653
- }
654
- }
655
- return best;
642
+ const activity = rank < 0 ? pathActivity(store, candidate) : -1;
643
+ return { candidate, rank, activity };
644
+ });
645
+ scored.sort((a, b) => {
646
+ if (a.rank !== b.rank) return b.rank - a.rank;
647
+ if (a.activity !== b.activity) return b.activity - a.activity;
648
+ return a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0;
649
+ });
650
+ return scored.map((entry) => entry.candidate);
651
+ }
652
+
653
+ export function pickRecentPath(store: HashStore, candidates: string[]): string {
654
+ return rankRecentPaths(store, candidates)[0]!;
656
655
  }
@@ -63,14 +63,14 @@ function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
63
63
  const parsed: unknown = JSON.parse(withoutDot);
64
64
  if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string") {
65
65
  warnings?.push(
66
- '[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
66
+ '[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
67
67
  );
68
68
  return parsed[0];
69
69
  }
70
70
  return line;
71
71
  } catch {
72
72
  warnings?.push(
73
- '[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
73
+ '[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
74
74
  );
75
75
  return match[1]!;
76
76
  }
@@ -90,7 +90,7 @@ export function parseText(edit: string[], warnings?: string[]): string[] {
90
90
  }
91
91
  if (split) {
92
92
  warnings?.push(
93
- "[E_BAD_SHAPE] replacement_lines contained embedded newlines; split into one line each.",
93
+ "[W_BAD_SHAPE] replacement_lines contained embedded newlines; split into one line each.",
94
94
  );
95
95
  }
96
96
  return out;
@@ -195,7 +195,7 @@ export function stripAnchorRow(
195
195
  : match[1] === "-"
196
196
  ? 'leading "-" marker'
197
197
  : '"anchor│" prefix';
198
- warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${clipLine(trimmed, 48)}".`);
198
+ warnings?.push(`[W_BAD_REF] Stripped ${marker} from ${entryLabel} "${clipLine(trimmed, 48)}".`);
199
199
  return match[2]!;
200
200
  }
201
201
 
@@ -241,7 +241,7 @@ export function stripBarePrefixes(
241
241
  .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
242
242
  .join(", ");
243
243
  warnings.push(
244
- `[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.`
244
+ `[W_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.`
245
245
  );
246
246
  return { ...edit, content_lines: contentLines };
247
247
  }
@@ -260,7 +260,7 @@ export function stripDiffPrefixes(
260
260
  if (stripped.length === 0) return edit;
261
261
  const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
262
262
  warnings.push(
263
- `[E_INVALID_PATCH] Stripped diff-preview marker from ${locations}.`
263
+ `[W_INVALID_PATCH] Stripped diff-preview marker from ${locations}.`
264
264
  );
265
265
  return { ...edit, content_lines: contentLines };
266
266
  }
@@ -285,7 +285,7 @@ export function swapReversedRanges(
285
285
  return edit;
286
286
  }
287
287
  warnings.push(
288
- `[E_BAD_OP] Swapped reversed remove_from/remove_to.`
288
+ `[W_BAD_OP] Swapped reversed remove_from/remove_to.`
289
289
  );
290
290
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
291
291
  }
package/src/insert.ts CHANGED
@@ -181,7 +181,7 @@ export function buildInsertToolDef(): InsertToolDef {
181
181
  if (isRec(canonical)) {
182
182
  const expanded = decodeStringArray(canonical.lines);
183
183
  if (expanded) {
184
- insertWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
184
+ insertWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
185
185
  canonical.lines = expanded;
186
186
  }
187
187
  }
@@ -1,4 +1,4 @@
1
- import { loadHashStore, findSnapshotPaths, findServedPaths, pickRecentPath } from "./hash-store";
1
+ import { loadHashStore, findSnapshotPaths, findServedPaths, rankRecentPaths } from "./hash-store";
2
2
  import { parseHashRef } from "./hashline/parse";
3
3
  import { stripAnchorRow } from "./hashline/resolve";
4
4
 
@@ -13,11 +13,14 @@ export async function resolvePathFromHashes(hashes: string[]): Promise<{ path: s
13
13
  if (matches.length === 0) return undefined;
14
14
  if (matches.length === 1) {
15
15
  const single = matches[0]!;
16
- return { path: single, warning: `[E_BAD_SHAPE] Missing "path" resolved to ${single}.` };
16
+ return { path: single, warning: `[W_BAD_SHAPE] Missing "path" resolved to ${single}.` };
17
17
  }
18
- const sorted = [...matches].sort();
19
- const picked = pickRecentPath(store, sorted);
20
- return { path: picked, warning: `[E_BAD_SHAPE] Missing "path" resolved to ${picked} (picked most recent of ${sorted.length}: ${sorted.join(", ")}).` };
18
+ const ranked = rankRecentPaths(store, [...matches]);
19
+ const picked = ranked[0]!;
20
+ const shown = ranked.slice(0, 3);
21
+ const hidden = ranked.length - shown.length;
22
+ const listed = hidden > 0 ? `${shown.join(", ")}, ... (+${hidden} more)` : shown.join(", ");
23
+ return { path: picked, warning: `[W_BAD_SHAPE] Missing "path" resolved to ${picked} (picked most recent of ${ranked.length}: ${listed}).` };
21
24
  }
22
25
 
23
26
  export async function resolveReplacePath(request: Record<string, unknown>): Promise<{ path: string; warning: string } | undefined> {
@@ -1,5 +1,5 @@
1
1
  import { Type } from "typebox";
2
- import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
2
+ import { isRec, normalizeAnchors, normalizeFilePath, rejectUnknownFields } from "./utils";
3
3
 
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
@@ -72,6 +72,7 @@ export function normReq(input: unknown): unknown {
72
72
  }
73
73
  const record: Record<string, unknown> = { ...input };
74
74
  normalizeFilePath(record);
75
+ normalizeAnchors(record);
75
76
  return record;
76
77
  }
77
78
 
@@ -2,6 +2,7 @@ import { Markdown, Text } from "@earendil-works/pi-tui";
2
2
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
3
  import type { ReplaceDetails } from "./replace";
4
4
  import { withLineNumbers } from "./utils";
5
+ import { isDedupRow } from "./replace-response";
5
6
  import { getPreviewInput } from "./payload-contract";
6
7
  export { getPreviewInput };
7
8
 
@@ -24,9 +25,10 @@ export type RRState = {
24
25
  type DiffRowKind = "added" | "removed" | "context";
25
26
 
26
27
  function diffRowKind(line: string): DiffRowKind {
27
- const stripped = line.replace(/^\s*\d+\s+│\s*/, "");
28
- if (stripped.startsWith("+") && !stripped.startsWith("+++")) return "added";
29
- if (stripped.startsWith("-") && !stripped.startsWith("---")) return "removed";
28
+ const withoutGutter = line.replace(/^\s*\d+\s+│\s*/, "");
29
+ if (isDedupRow(withoutGutter) || isDedupRow(withoutGutter.replace(/^\s*│\s*/, ""))) return "removed";
30
+ if (withoutGutter.startsWith("+") && !withoutGutter.startsWith("+++")) return "added";
31
+ if (withoutGutter.startsWith("-") && !withoutGutter.startsWith("---")) return "removed";
30
32
  return "context";
31
33
  }
32
34
 
@@ -137,6 +137,10 @@ export function fmtDedupRow(line: string): string {
137
137
  return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
138
138
  }
139
139
 
140
+ export function isDedupRow(line: string): boolean {
141
+ return line.startsWith(`${DEDUP_ANCHOR}${HASH_SEP}`);
142
+ }
143
+
140
144
  export function isChangeRow(line: string): boolean {
141
145
  return line.startsWith("+") || line.startsWith("-");
142
146
  }
package/src/replace.ts CHANGED
@@ -130,7 +130,7 @@ export async function execPipeline(
130
130
  let replacementLines = params.replacement_lines;
131
131
  const expandedReplacement = decodeStringArray(replacementLines);
132
132
  if (expandedReplacement) {
133
- editWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
133
+ editWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
134
134
  replacementLines = expandedReplacement;
135
135
  }
136
136
  const edit = resEdit(
@@ -286,7 +286,7 @@ export function buildToolDef(): ToolDef {
286
286
  { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
287
287
  );
288
288
  const appliedWarnings = boundaryBypass
289
- ? ["[E_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
289
+ ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
290
290
  : [];
291
291
  return commitEdit(pipe, {
292
292
  path,
package/src/utils.ts CHANGED
@@ -9,11 +9,23 @@ export function normalizeFilePath(record: Record<string, unknown>): void {
9
9
  }
10
10
  }
11
11
 
12
+ export function normalizeAnchors(record: Record<string, unknown>): void {
13
+ if (typeof record.remove_from !== "string" && typeof record.replace_from === "string") {
14
+ record.remove_from = record.replace_from;
15
+ delete record.replace_from;
16
+ }
17
+ if (typeof record.remove_to !== "string" && typeof record.replace_to === "string") {
18
+ record.remove_to = record.replace_to;
19
+ delete record.replace_to;
20
+ }
21
+ }
22
+
12
23
  export function makePrepareArguments(): (args: unknown) => any {
13
24
  return (args) => {
14
25
  if (!isRec(args)) return args;
15
26
  const record = { ...args };
16
27
  normalizeFilePath(record);
28
+ normalizeAnchors(record);
17
29
  return record;
18
30
  };
19
31
  }