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 +14 -7
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/replace-guidelines.md +1 -0
- package/src/hash-store/cache.ts +22 -0
- package/src/hash-store.ts +55 -5
- package/src/hashline/parse.ts +3 -3
- package/src/hashline/resolve.ts +4 -4
- package/src/insert.ts +20 -11
- package/src/missing-path.ts +54 -0
- package/src/payload-contract.ts +8 -9
- package/src/replace-render.ts +21 -16
- package/src/replace-response.ts +4 -0
- package/src/replace-undo.ts +7 -1
- package/src/replace.ts +11 -41
- package/src/served.ts +2 -0
- package/src/utils.ts +12 -0
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
|
|
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 `[
|
|
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
|
-
| `[
|
|
233
|
-
| `[
|
|
234
|
-
| `[
|
|
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
|
-
| `[
|
|
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.
|
|
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.
|
package/src/hash-store/cache.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hashline/parse.ts
CHANGED
|
@@ -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
|
-
'[
|
|
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
|
-
'[
|
|
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
|
-
"[
|
|
93
|
+
"[W_BAD_SHAPE] replacement_lines contained embedded newlines; split into one line each.",
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
return out;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -195,7 +195,7 @@ export function stripAnchorRow(
|
|
|
195
195
|
: match[1] === "-"
|
|
196
196
|
? 'leading "-" marker'
|
|
197
197
|
: '"anchor│" prefix';
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
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('[
|
|
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
|
+
}
|
package/src/payload-contract.ts
CHANGED
|
@@ -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):
|
|
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)
|
|
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
|
}
|
package/src/replace-render.ts
CHANGED
|
@@ -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
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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";
|
package/src/replace-response.ts
CHANGED
|
@@ -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-undo.ts
CHANGED
|
@@ -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,
|
|
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('[
|
|
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
|
|
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
|
-
? ["[
|
|
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
|
}
|