pi-hashline-edit-pro 3.0.2 → 3.0.3

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,7 +99,7 @@ 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
+ - 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.
102
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.
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).
@@ -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 the candidates 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.
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.3",
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,45 @@ 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 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) {
644
+ 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;
656
+ }
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
 
@@ -178,6 +185,8 @@ export function buildInsertToolDef(): InsertToolDef {
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,51 @@
1
+ import { loadHashStore, findSnapshotPaths, findServedPaths, pickRecentPath } 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: `[E_BAD_SHAPE] Missing "path" resolved to ${single}.` };
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(", ")}).` };
21
+ }
22
+
23
+ export async function resolveReplacePath(request: Record<string, unknown>): Promise<{ path: string; warning: string } | undefined> {
24
+ if (typeof request.path === "string") return undefined;
25
+ const from = request.remove_from;
26
+ const to = request.remove_to;
27
+ if (typeof from !== "string" || typeof to !== "string") return undefined;
28
+ const hashes: string[] = [];
29
+ for (const ref of [from, to]) {
30
+ try {
31
+ hashes.push(parseHashRef(ref).hash);
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ return resolvePathFromHashes(hashes);
37
+ }
38
+
39
+ export async function resolveInsertPath(request: Record<string, unknown>): Promise<{ path: string; warning: string } | undefined> {
40
+ if (typeof request.path === "string") return undefined;
41
+ const anchor = request.anchor;
42
+ if (typeof anchor !== "string") return undefined;
43
+ let hash: string;
44
+ try {
45
+ const stripped = stripAnchorRow(anchor.trim(), "anchor entry");
46
+ hash = parseHashRef(stripped).hash;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ return resolvePathFromHashes([hash]);
51
+ }
@@ -75,16 +75,14 @@ export function normReq(input: unknown): unknown {
75
75
  return record;
76
76
  }
77
77
 
78
- export function getPreviewInput(args: unknown): ReqParams | null {
78
+ export function getPreviewInput(args: unknown): { path?: string; remove_from: string; remove_to: string; replacement_lines: string[] } | null {
79
79
  let normalized: unknown;
80
80
  try {
81
81
  normalized = normReq(args);
82
82
  } catch {
83
83
  return null;
84
84
  }
85
- if (!isRec(normalized) || typeof normalized.path !== "string") {
86
- return null;
87
- }
85
+ if (!isRec(normalized)) return null;
88
86
  if (
89
87
  typeof normalized.remove_from !== "string" ||
90
88
  typeof normalized.remove_to !== "string" ||
@@ -94,9 +92,9 @@ export function getPreviewInput(args: unknown): ReqParams | null {
94
92
  return null;
95
93
  }
96
94
  return {
97
- path: normalized.path,
98
- remove_from: normalized.remove_from,
99
- remove_to: normalized.remove_to,
100
- replacement_lines: normalized.replacement_lines,
95
+ ...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
96
+ remove_from: normalized.remove_from as string,
97
+ remove_to: normalized.remove_to as string,
98
+ replacement_lines: normalized.replacement_lines as string[],
101
99
  };
102
100
  }
@@ -12,8 +12,7 @@ export type MdTheme = Pick<
12
12
  "fg" | "bold" | "italic" | "underline" | "strikethrough"
13
13
  >;
14
14
 
15
- export type RPreview = { diff: string } | { error: string };
16
-
15
+ export type RPreview = { diff: string; path?: string } | { error: string; path?: string };
17
16
  export type RRState = {
18
17
  argsKey?: string;
19
18
  preview?: RPreview;
@@ -65,17 +64,21 @@ export function fmtResult(diff: string, theme: FgT): string {
65
64
  }
66
65
 
67
66
  export function fmtCall(
68
- args: { path?: string } | undefined,
69
- state: RRState,
70
- expanded: boolean,
71
- theme: CallT,
72
- toolName = "replace",
67
+ args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | undefined,
68
+ state: RRState,
69
+ expanded: boolean,
70
+ theme: CallT,
71
+ toolName = "replace",
73
72
  ): 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", "...");
73
+ const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
74
+ const path = args?.path ?? previewPath;
75
+ 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;
76
+ const pathDisplay =
77
+ typeof path === "string" && path.length > 0
78
+ ? theme.fg("accent", path)
79
+ : typeof anchorFallback === "string" && anchorFallback.length > 0
80
+ ? theme.fg("accent", anchorFallback)
81
+ : theme.fg("toolOutput", "...");
79
82
  let text = `${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`;
80
83
 
81
84
  if (!state.preview) {
@@ -234,7 +237,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
234
237
 
235
238
  export function makeRenderCall(
236
239
  preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
237
- options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
240
+ options: { getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | null; toolName?: string } = {},
238
241
  ) {
239
242
  const getInput = options.getInput ?? getPreviewInput;
240
243
  const toolName = options.toolName ?? "replace";
@@ -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;
@@ -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
  }
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);