pi-hashline-edit-pro 3.0.2 → 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
@@ -91,6 +91,7 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
91
91
 
92
92
  | Field | Description |
93
93
  | --- | --- |
94
+ | `path` | Path to edit; always provide it explicitly — it is only auto-resolved from the anchors as a fallback. |
94
95
  | `remove_from` | 4-char anchor from `read` output marking the FIRST line to remove (inclusive). |
95
96
  | `remove_to` | 4-char anchor from `read` output marking the LAST line to remove (inclusive). |
96
97
  | `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line. |
@@ -98,8 +99,8 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
98
99
  Notes:
99
100
 
100
101
  - The request is checked before any file I/O, so a bad request never touches the file.
101
- - 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 uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all five tools.
102
- - 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.
103
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.
104
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).
105
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.
@@ -119,12 +120,14 @@ Notes:
119
120
 
120
121
  | Field | Description |
121
122
  | --- | --- |
123
+ | `path` | Path to edit; always provide it explicitly — it is only auto-resolved from the anchors as a fallback. |
122
124
  | `anchor` | 4-char anchor from `read` output marking the line next to which the lines go (inclusive; the line is preserved). A pasted diff row like `+Hasu│x` or an `anchor│` prefix is stripped automatically with a warning. |
123
125
  | `direction` | `"after"` to insert below the anchor line, `"before"` to insert above it. |
124
126
  | `lines` | Lines to insert as an array of strings, one element per line. Mirror `replacement_lines` semantics: use `[""]` for a blank line and do not embed `\n` inside an element. The anchor line is never part of `lines`. |
125
127
 
126
128
  Notes:
127
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 up to 3 candidate paths named.
128
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`.
129
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`.
130
133
  - To seed an empty file, read it and insert after the `anchor│` empty-line row.
@@ -221,17 +224,21 @@ Two guarantees make this safe even with duplicated content:
221
224
 
222
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`.
223
226
 
224
- ## 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).
225
230
 
226
231
  | Code | Meaning |
227
232
  | --- | --- |
228
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). |
229
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. |
230
237
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
231
238
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
232
- | `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
233
- | `[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. |
234
- | `[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. |
235
242
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
236
243
  | `[E_NOT_FOUND]` | The path does not exist. |
237
244
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -239,7 +246,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
239
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. |
240
247
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
241
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. |
242
- | `[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. |
243
250
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 257,795-line hashline limit or the 100MB size limit. |
244
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). |
245
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.2",
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",
@@ -1 +1,2 @@
1
- - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
1
+ - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
2
+ - `insert`: always include explicit `path`/`file_path`, even when the file can be inferred from anchors. Do not rely on anchor-based path inference.
@@ -1,4 +1,5 @@
1
1
  - `replace`: use the same anchor for `remove_from` and `remove_to` to change one line.
2
+ - `replace`: always include explicit `path`/`file_path`, even when the file can be inferred from anchors. Do not rely on anchor-based path inference.
2
3
  - `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically.
3
4
  - `replace`: keep the range tight — only lines that actually change — and copy leading spaces exactly.
4
5
  - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. One edit per turn; check the diff before the next edit on that file.
@@ -16,3 +16,25 @@ export function cacheSnapshot(path: string, checksum: string, lineCount: number,
16
16
  if (oldest !== undefined) snapshotCache.delete(oldest);
17
17
  }
18
18
  }
19
+
20
+ let sessionSeq = 0;
21
+ export const sessionTouched = new Map<string, number>();
22
+
23
+ export function touchSession(path: string): void {
24
+ sessionSeq += 1;
25
+ sessionTouched.delete(path);
26
+ sessionTouched.set(path, sessionSeq);
27
+ }
28
+
29
+ export function sessionRank(path: string): number | undefined {
30
+ return sessionTouched.get(path);
31
+ }
32
+
33
+ export function clearSession(): void {
34
+ sessionTouched.clear();
35
+ sessionSeq = 0;
36
+ }
37
+
38
+ export function forgetSession(path: string): void {
39
+ sessionTouched.delete(path);
40
+ }
package/src/hash-store.ts CHANGED
@@ -20,11 +20,7 @@ import {
20
20
  retriedWrite,
21
21
  openDbWithBusyRetryAsync,
22
22
  } from "./hash-store/retry";
23
- import {
24
- snapshotCache,
25
- cacheSnapshot,
26
- SNAPSHOT_CACHE_LIMIT,
27
- } from "./hash-store/cache";
23
+ import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT, touchSession, sessionRank, clearSession, forgetSession } from "./hash-store/cache";
28
24
 
29
25
  export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parseStoredHashes, parseStoredServed, isCorruptionError };
30
26
  export { SNAPSHOT_CACHE_LIMIT };
@@ -99,6 +95,9 @@ interface Prepared {
99
95
  servedGet: (...params: SqlParams) => Record<string, unknown> | undefined;
100
96
  servedUpsert: (...params: SqlParams) => void;
101
97
  servedDelete: (...params: SqlParams) => void;
98
+ snapshotTime: (...params: SqlParams) => Record<string, unknown> | undefined;
99
+ undoTime: (...params: SqlParams) => Record<string, unknown> | undefined;
100
+ servedTime: (...params: SqlParams) => Record<string, unknown> | undefined;
102
101
  }
103
102
 
104
103
  export interface HashStore {
@@ -199,6 +198,9 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
199
198
  "ON CONFLICT(path) DO UPDATE SET hashes = excluded.hashes, updated_at = excluded.updated_at"
200
199
  );
201
200
  const servedDelStmt = db.prepare("DELETE FROM served WHERE path = ?");
201
+ const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
202
+ const undoTimeStmt = db.prepare("SELECT updated_at FROM undo WHERE path = ?");
203
+ const servedTimeStmt = db.prepare("SELECT updated_at FROM served WHERE path = ?");
202
204
  const stmts: Prepared = {
203
205
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
204
206
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
@@ -212,6 +214,9 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
212
214
  servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
213
215
  servedUpsert: retriedWrite(servedUpsertStmt),
214
216
  servedDelete: retriedWrite(servedDelStmt),
217
+ snapshotTime: (...params) => snapshotTimeStmt.get(...params) as Record<string, unknown> | undefined,
218
+ undoTime: (...params) => undoTimeStmt.get(...params) as Record<string, unknown> | undefined,
219
+ servedTime: (...params) => servedTimeStmt.get(...params) as Record<string, unknown> | undefined,
215
220
  };
216
221
  return { db, stmts };
217
222
  }
@@ -345,6 +350,7 @@ export function shutdownHashStore(): void {
345
350
  cachedDb = null;
346
351
  }
347
352
  snapshotCache.clear();
353
+ clearSession();
348
354
  }
349
355
 
350
356
  export function withStore(fn: () => void): void {
@@ -462,6 +468,7 @@ export function upsertSnapshot(
462
468
  ): void {
463
469
  store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
464
470
  cacheSnapshot(path, checksum, lineCount, hashes);
471
+ touchSession(path);
465
472
  }
466
473
  export function persistSnapshot(
467
474
  store: HashStore,
@@ -482,6 +489,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
482
489
  entry.resultContent,
483
490
  Date.now(),
484
491
  );
492
+ touchSession(path);
485
493
  }
486
494
 
487
495
  export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
@@ -540,6 +548,7 @@ export async function pruneMissing(store: HashStore): Promise<void> {
540
548
  }
541
549
  });
542
550
  for (const path of missing) snapshotCache.delete(path);
551
+ for (const path of missing) forgetSession(path);
543
552
  }
544
553
 
545
554
  function matchPathsByHashes(
@@ -603,3 +612,44 @@ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[]
603
612
  export function findServedPaths(store: HashStore, hashes: string[]): string[] {
604
613
  return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
605
614
  }
615
+
616
+ export function pathActivity(store: HashStore, path: string): number {
617
+ let activity = 0;
618
+ try {
619
+ const snap = withBusyRetry(() => store.stmts.snapshotTime(path)) as { updated_at?: unknown } | undefined;
620
+ const snapTime = typeof snap?.updated_at === "number" ? snap.updated_at : 0;
621
+ if (snapTime > activity) activity = snapTime;
622
+ } catch {
623
+ }
624
+ try {
625
+ const undo = withBusyRetry(() => store.stmts.undoTime(path)) as { updated_at?: unknown } | undefined;
626
+ const undoTime = typeof undo?.updated_at === "number" ? undo.updated_at : 0;
627
+ if (undoTime > activity) activity = undoTime;
628
+ } catch {
629
+ }
630
+ try {
631
+ const served = withBusyRetry(() => store.stmts.servedTime(path)) as { updated_at?: unknown } | undefined;
632
+ const servedTime = typeof served?.updated_at === "number" ? served.updated_at : 0;
633
+ if (servedTime > activity) activity = servedTime;
634
+ } catch {
635
+ }
636
+ return activity;
637
+ }
638
+
639
+ export function rankRecentPaths(store: HashStore, candidates: string[]): string[] {
640
+ const scored = candidates.map((candidate) => {
641
+ const rank = sessionRank(candidate) ?? -1;
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]!;
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
@@ -10,6 +10,7 @@ import { loadP, loadGuide } from "./prompts";
10
10
  import { normReq } from "./payload-contract";
11
11
  import { decodeStringArray, isRec, rejectUnknownFields, splitLines } from "./utils";
12
12
  import { clearBoundaryBypass } from "./boundary-bypass";
13
+ import { resolveInsertPath } from "./missing-path";
13
14
  import type { RPreview, RRState } from "./replace-render";
14
15
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
15
16
 
@@ -43,10 +44,12 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
43
44
 
44
45
  const insertToolSchema = Type.Object(
45
46
  {
46
- path: Type.String({
47
- description:
48
- "Path to the file to edit",
49
- }),
47
+ path: Type.Optional(
48
+ Type.String({
49
+ description:
50
+ "Path to edit; always provide it explicitly — it is only auto-resolved from the anchors as a fallback.",
51
+ }),
52
+ ),
50
53
  anchor: Type.String({
51
54
  description:
52
55
  'Bare 4-char anchor from a read row like `Hasu│content`, never the content. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
@@ -108,6 +111,10 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
108
111
  const expanded = decodeStringArray(normalized.lines);
109
112
  if (expanded) normalized.lines = expanded;
110
113
  }
114
+ if (isRec(normalized)) {
115
+ const resolution = await resolveInsertPath(normalized);
116
+ if (resolution) normalized.path = resolution.path;
117
+ }
111
118
  assertInsertReq(normalized);
112
119
  const { ref } = parseInsertAnchor(normalized.anchor);
113
120
  const preload = await readNormFile(normalized.path, cwd, {
@@ -138,7 +145,7 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
138
145
  } catch {
139
146
  return null;
140
147
  }
141
- if (!isRec(normalized) || typeof normalized.path !== "string") return null;
148
+ if (!isRec(normalized)) return null;
142
149
  if (
143
150
  typeof normalized.anchor !== "string" ||
144
151
  (normalized.direction !== "before" && normalized.direction !== "after") ||
@@ -148,10 +155,10 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
148
155
  return null;
149
156
  }
150
157
  return {
151
- path: normalized.path,
152
- anchor: normalized.anchor,
153
- direction: normalized.direction,
154
- lines: normalized.lines,
158
+ ...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
159
+ anchor: normalized.anchor as string,
160
+ direction: normalized.direction as "before" | "after",
161
+ lines: normalized.lines as string[],
155
162
  };
156
163
  }
157
164
 
@@ -174,10 +181,12 @@ export function buildInsertToolDef(): InsertToolDef {
174
181
  if (isRec(canonical)) {
175
182
  const expanded = decodeStringArray(canonical.lines);
176
183
  if (expanded) {
177
- 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.');
178
185
  canonical.lines = expanded;
179
186
  }
180
187
  }
188
+ const resolution = isRec(canonical) ? await resolveInsertPath(canonical) : undefined;
189
+ if (resolution && isRec(canonical)) canonical.path = resolution.path;
181
190
  assertInsertReq(canonical);
182
191
  const req = canonical;
183
192
  const path = req.path;
@@ -203,7 +212,7 @@ export function buildInsertToolDef(): InsertToolDef {
203
212
  verb: "inserted",
204
213
  noopNoun: "Insertion",
205
214
  foldedAnchorLines: anchorLine === undefined ? 0 : 1,
206
- prefixWarnings: [...anchorWarnings, ...insertWarnings],
215
+ prefixWarnings: [...(resolution ? [resolution.warning] : []), ...anchorWarnings, ...insertWarnings],
207
216
  onApplied: () => clearBoundaryBypass(mutationTargetPath),
208
217
  });
209
218
  });
@@ -0,0 +1,54 @@
1
+ import { loadHashStore, findSnapshotPaths, findServedPaths, rankRecentPaths } from "./hash-store";
2
+ import { parseHashRef } from "./hashline/parse";
3
+ import { stripAnchorRow } from "./hashline/resolve";
4
+
5
+ export async function resolvePathFromHashes(hashes: string[]): Promise<{ path: string; warning: string } | undefined> {
6
+ let store;
7
+ try {
8
+ store = await loadHashStore();
9
+ } catch {
10
+ return undefined;
11
+ }
12
+ const matches = [...new Set([...findSnapshotPaths(store, hashes), ...findServedPaths(store, hashes)])];
13
+ if (matches.length === 0) return undefined;
14
+ if (matches.length === 1) {
15
+ const single = matches[0]!;
16
+ return { path: single, warning: `[W_BAD_SHAPE] Missing "path" resolved to ${single}.` };
17
+ }
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}).` };
24
+ }
25
+
26
+ export async function resolveReplacePath(request: Record<string, unknown>): Promise<{ path: string; warning: string } | undefined> {
27
+ if (typeof request.path === "string") return undefined;
28
+ const from = request.remove_from;
29
+ const to = request.remove_to;
30
+ if (typeof from !== "string" || typeof to !== "string") return undefined;
31
+ const hashes: string[] = [];
32
+ for (const ref of [from, to]) {
33
+ try {
34
+ hashes.push(parseHashRef(ref).hash);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ return resolvePathFromHashes(hashes);
40
+ }
41
+
42
+ export async function resolveInsertPath(request: Record<string, unknown>): Promise<{ path: string; warning: string } | undefined> {
43
+ if (typeof request.path === "string") return undefined;
44
+ const anchor = request.anchor;
45
+ if (typeof anchor !== "string") return undefined;
46
+ let hash: string;
47
+ try {
48
+ const stripped = stripAnchorRow(anchor.trim(), "anchor entry");
49
+ hash = parseHashRef(stripped).hash;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ return resolvePathFromHashes([hash]);
54
+ }
@@ -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,19 +72,18 @@ 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
 
78
- export function getPreviewInput(args: unknown): ReqParams | null {
79
+ export function getPreviewInput(args: unknown): { path?: string; remove_from: string; remove_to: string; replacement_lines: string[] } | null {
79
80
  let normalized: unknown;
80
81
  try {
81
82
  normalized = normReq(args);
82
83
  } catch {
83
84
  return null;
84
85
  }
85
- if (!isRec(normalized) || typeof normalized.path !== "string") {
86
- return null;
87
- }
86
+ if (!isRec(normalized)) return null;
88
87
  if (
89
88
  typeof normalized.remove_from !== "string" ||
90
89
  typeof normalized.remove_to !== "string" ||
@@ -94,9 +93,9 @@ export function getPreviewInput(args: unknown): ReqParams | null {
94
93
  return null;
95
94
  }
96
95
  return {
97
- path: normalized.path,
98
- remove_from: normalized.remove_from,
99
- remove_to: normalized.remove_to,
100
- replacement_lines: normalized.replacement_lines,
96
+ ...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
97
+ remove_from: normalized.remove_from as string,
98
+ remove_to: normalized.remove_to as string,
99
+ replacement_lines: normalized.replacement_lines as string[],
101
100
  };
102
101
  }
@@ -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
 
@@ -12,8 +13,7 @@ export type MdTheme = Pick<
12
13
  "fg" | "bold" | "italic" | "underline" | "strikethrough"
13
14
  >;
14
15
 
15
- export type RPreview = { diff: string } | { error: string };
16
-
16
+ export type RPreview = { diff: string; path?: string } | { error: string; path?: string };
17
17
  export type RRState = {
18
18
  argsKey?: string;
19
19
  preview?: RPreview;
@@ -25,9 +25,10 @@ export type RRState = {
25
25
  type DiffRowKind = "added" | "removed" | "context";
26
26
 
27
27
  function diffRowKind(line: string): DiffRowKind {
28
- const stripped = line.replace(/^\s*\d+\s+│\s*/, "");
29
- if (stripped.startsWith("+") && !stripped.startsWith("+++")) return "added";
30
- 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";
31
32
  return "context";
32
33
  }
33
34
 
@@ -65,17 +66,21 @@ export function fmtResult(diff: string, theme: FgT): string {
65
66
  }
66
67
 
67
68
  export function fmtCall(
68
- args: { path?: string } | undefined,
69
- state: RRState,
70
- expanded: boolean,
71
- theme: CallT,
72
- toolName = "replace",
69
+ args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | undefined,
70
+ state: RRState,
71
+ expanded: boolean,
72
+ theme: CallT,
73
+ toolName = "replace",
73
74
  ): string {
74
- const path = args?.path;
75
- const pathDisplay =
76
- typeof path === "string" && path.length > 0
77
- ? theme.fg("accent", path)
78
- : theme.fg("toolOutput", "...");
75
+ const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
76
+ const path = args?.path ?? previewPath;
77
+ const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : undefined;
78
+ const pathDisplay =
79
+ typeof path === "string" && path.length > 0
80
+ ? theme.fg("accent", path)
81
+ : typeof anchorFallback === "string" && anchorFallback.length > 0
82
+ ? theme.fg("accent", anchorFallback)
83
+ : theme.fg("toolOutput", "...");
79
84
  let text = `${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`;
80
85
 
81
86
  if (!state.preview) {
@@ -234,7 +239,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
234
239
 
235
240
  export function makeRenderCall(
236
241
  preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
237
- options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
242
+ options: { getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | null; toolName?: string } = {},
238
243
  ) {
239
244
  const getInput = options.getInput ?? getPreviewInput;
240
245
  const toolName = options.toolName ?? "replace";
@@ -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
  }
@@ -11,7 +11,8 @@ import { genDiff, genPatch } from "./replace-diff";
11
11
  import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
14
- import { renderEditResult } from "./replace-render";
14
+ import { renderEditResult, fmtCall } from "./replace-render";
15
+ import { Text } from "@earendil-works/pi-tui";
15
16
  import { changedRange, lineHashes } from "./hashline";
16
17
  export interface UndoEntry {
17
18
  content: string;
@@ -100,6 +101,11 @@ export function regUndo(pi: ExtensionAPI): void {
100
101
  }),
101
102
  }),
102
103
  executionMode: "sequential",
104
+ renderCall(args: any, theme: any, context: any) {
105
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
106
+ text.setText(fmtCall(args as { path?: string } | undefined, { preview: undefined } as never, context.expanded === true, theme, "undo_last_change"));
107
+ return text;
108
+ },
103
109
  renderResult(result, opts, theme, context) {
104
110
  return renderEditResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
105
111
  },
package/src/replace.ts CHANGED
@@ -15,7 +15,6 @@ import { type FileIdentity } from "./fs-write";
15
15
  import { applyEdit,
16
16
  lineHashes,
17
17
  resEdit,
18
- parseHashRef,
19
18
  MAX_HASH_LINES,
20
19
  RangeStaleError,
21
20
  AnchorMismatchError,
@@ -28,7 +27,8 @@ import {
28
27
  type RPreview,
29
28
  type RRState,
30
29
  } from "./replace-render";
31
- import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
30
+ import { loadHashStore, type HashStore } from "./hash-store";
31
+ import { resolveReplacePath } from "./missing-path";
32
32
  import { getServed, recordServedSafe } from "./served";
33
33
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
34
34
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
@@ -69,41 +69,6 @@ export interface PipelineResult {
69
69
  identity: FileIdentity;
70
70
  }
71
71
 
72
- async function resolveMissingPath(
73
- request: Record<string, unknown>,
74
- ): Promise<{ path: string; warning: string } | undefined> {
75
- if (typeof request.path === "string") return undefined;
76
- const from = request.remove_from;
77
- const to = request.remove_to;
78
- if (typeof from !== "string" || typeof to !== "string") return undefined;
79
- const hashes: string[] = [];
80
- for (const ref of [from, to]) {
81
- try {
82
- hashes.push(parseHashRef(ref).hash);
83
- } catch {
84
- return undefined;
85
- }
86
- }
87
- let store: HashStore;
88
- try {
89
- store = await loadHashStore();
90
- } catch {
91
- return undefined;
92
- }
93
- const matches = [...new Set([...findSnapshotPaths(store, hashes), ...findServedPaths(store, hashes)])];
94
- if (matches.length === 1) {
95
- return {
96
- path: matches[0]!,
97
- warning: `[E_BAD_SHAPE] Missing "path" resolved to ${matches[0]}.`,
98
- };
99
- }
100
- if (matches.length > 1) {
101
- throw new Error(
102
- `[E_BAD_SHAPE] Edit request requires a non-empty "path" string; the anchors match multiple known files: ${matches.join(", ")}.`,
103
- );
104
- }
105
- return undefined;
106
- }
107
72
 
108
73
  export interface ExecPipelineOptions {
109
74
  accessMode?: number;
@@ -165,7 +130,7 @@ export async function execPipeline(
165
130
  let replacementLines = params.replacement_lines;
166
131
  const expandedReplacement = decodeStringArray(replacementLines);
167
132
  if (expandedReplacement) {
168
- 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.');
169
134
  replacementLines = expandedReplacement;
170
135
  }
171
136
  const edit = resEdit(
@@ -249,10 +214,11 @@ export function previewFromPipe(pipe: PipelineResult): RPreview {
249
214
  if (pipe.originalNormalized === pipe.result) {
250
215
  return {
251
216
  error: `No changes made to ${pipe.path}. The edit produced identical content.`,
217
+ path: pipe.path,
252
218
  };
253
219
  }
254
220
  const base = genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes);
255
- return { diff: withDedupRows(base.diff, base.lineNumbers, pipe.boundaryDedupAbove, pipe.boundaryDedupBelow).diff };
221
+ return { diff: withDedupRows(base.diff, base.lineNumbers, pipe.boundaryDedupAbove, pipe.boundaryDedupBelow).diff, path: pipe.path };
256
222
  }
257
223
  export function previewError(error: unknown): RPreview {
258
224
  return { error: error instanceof Error ? error.message : String(error) };
@@ -264,6 +230,10 @@ export async function compPreview(
264
230
  ): Promise<RPreview> {
265
231
  try {
266
232
  const normalized = normReq(request);
233
+ if (isRec(normalized)) {
234
+ const resolution = await resolveReplacePath(normalized);
235
+ if (resolution) normalized.path = resolution.path;
236
+ }
267
237
  assertReq(normalized);
268
238
  const pipe = await execPipeline(
269
239
  normalized,
@@ -300,7 +270,7 @@ export function buildToolDef(): ToolDef {
300
270
  renderResult: editRenderResultWrapper,
301
271
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
302
272
  const canonical = normReq(params);
303
- const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
273
+ const resolution = isRec(canonical) ? await resolveReplacePath(canonical) : undefined;
304
274
  if (resolution && isRec(canonical)) {
305
275
  canonical.path = resolution.path;
306
276
  }
@@ -316,7 +286,7 @@ export function buildToolDef(): ToolDef {
316
286
  { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
317
287
  );
318
288
  const appliedWarnings = boundaryBypass
319
- ? ["[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."]
320
290
  : [];
321
291
  return commitEdit(pipe, {
322
292
  path,
package/src/served.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
+ import { touchSession } from "./hash-store/cache";
2
3
  import { withBusyRetry } from "./hash-store/retry";
3
4
  import { HASH_CLASS } from "./hashline/alphabet";
4
5
  import { contentChecksum } from "./hashline/hasher";
@@ -81,6 +82,7 @@ export function recordServed(
81
82
  entries: Map<string, string>,
82
83
  scope?: ReadonlySet<string>,
83
84
  ): void {
85
+ touchSession(path);
84
86
  try {
85
87
  withStore(() => {
86
88
  const map = computeUpdate(store, path, entries, scope);
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
  }