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