pi-hashline-edit-pro 6.4.3 → 6.4.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 +3 -2
- package/index.ts +10 -4
- package/package.json +1 -1
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/resolve.ts +2 -0
- package/src/replace-match.ts +42 -8
- package/src/replace.ts +4 -2
- package/tool-prompts/read.md +1 -1
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
|
|
|
159
159
|
|
|
160
160
|
### read
|
|
161
161
|
|
|
162
|
-
`read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return
|
|
162
|
+
`read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return starting at that anchor row. Anchor offsets are stable across edits and return the same editable rows.
|
|
163
163
|
|
|
164
164
|
| Parameter | Description |
|
|
165
165
|
| --- | --- |
|
|
@@ -207,7 +207,7 @@ A deletion keeps blank lines at the edges of the removed range, so the separator
|
|
|
207
207
|
|
|
208
208
|
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
209
209
|
|
|
210
|
-
Auto-fixable slips fall into two groups. Fixed silently:
|
|
210
|
+
Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`) and diff-preview rows pasted into the replacement are stripped with a warning.
|
|
211
211
|
|
|
212
212
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to the `text` field of both `replace` and `insert`.
|
|
213
213
|
|
|
@@ -473,6 +473,7 @@ Full reference:
|
|
|
473
473
|
| `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
|
|
474
474
|
| `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
|
|
475
475
|
| `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
|
|
476
|
+
| `[H_RANGE_REVERSED]` | The `remove_from` anchor resolved to a line after `remove_to`. The call applied the range in document order; the one-line hint names the line range used. |
|
|
476
477
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
477
478
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
478
479
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
package/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
setDisableOnModelsFromText,
|
|
34
34
|
} from "./src/config";
|
|
35
35
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
36
|
-
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
36
|
+
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
37
37
|
import { serveRows } from "./src/served";
|
|
38
38
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
39
39
|
import { currentEditFlags } from "./src/edit-common";
|
|
@@ -72,6 +72,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
72
72
|
let grepFlavorCwd: string | undefined;
|
|
73
73
|
const baseAnchorTools = new Set<string>();
|
|
74
74
|
let gateApplied = false;
|
|
75
|
+
const pendingRegistrySessionKeys: Array<string | undefined> = [];
|
|
75
76
|
|
|
76
77
|
async function refreshEditTools(): Promise<void> {
|
|
77
78
|
try {
|
|
@@ -144,7 +145,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
144
145
|
pi.setActiveTools([...new Set([...base, ...enabled])]);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
149
|
+
pendingRegistrySessionKeys.push(sessionKeyFor(ctx));
|
|
150
|
+
return withAnchorSession(ctx, fn);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pi.on("session_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
|
|
148
154
|
const active = pi.getActiveTools();
|
|
149
155
|
baseAnchorTools.clear();
|
|
150
156
|
for (const tool of ANCHOR_TOOL_NAMES) {
|
|
@@ -191,9 +197,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
191
197
|
}
|
|
192
198
|
}));
|
|
193
199
|
|
|
194
|
-
pi.on("session_shutdown", async (
|
|
200
|
+
pi.on("session_shutdown", async () => {
|
|
195
201
|
try {
|
|
196
|
-
const key =
|
|
202
|
+
const key = pendingRegistrySessionKeys.shift();
|
|
197
203
|
if (key !== undefined) releaseRegistrySession(key);
|
|
198
204
|
} catch (error) {
|
|
199
205
|
console.error("Failed to release anchor registry session:", error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.4.
|
|
3
|
+
"version": "6.4.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/hashline/apply.ts
CHANGED
|
@@ -166,7 +166,7 @@ export function planEdit(
|
|
|
166
166
|
const fileHashes = precomputedHashes;
|
|
167
167
|
const warnings: string[] = [];
|
|
168
168
|
|
|
169
|
-
const rangeFixed = swapReversedRanges(edit, fileHashes);
|
|
169
|
+
const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
|
|
170
170
|
const prefixFixed = stripDiffPrefixes(
|
|
171
171
|
stripBarePrefixes(rangeFixed, warnings, options?.stripWarning),
|
|
172
172
|
warnings,
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -265,6 +265,7 @@ export function stripDiffPrefixes(edit: HEdit, warnings: string[], location: Str
|
|
|
265
265
|
export function swapReversedRanges(
|
|
266
266
|
edit: HEdit,
|
|
267
267
|
fileHashes: string[],
|
|
268
|
+
warnings?: string[],
|
|
268
269
|
): HEdit {
|
|
269
270
|
const lineByHash = new Map<string, number>();
|
|
270
271
|
for (let i = 0; i < fileHashes.length; i++) {
|
|
@@ -280,6 +281,7 @@ export function swapReversedRanges(
|
|
|
280
281
|
) {
|
|
281
282
|
return edit;
|
|
282
283
|
}
|
|
284
|
+
warnings?.push(`[H_RANGE_REVERSED] "remove_from" refers to line ${startLine} after "remove_to" (line ${endLine}); lines ${endLine}-${startLine} were replaced in document order.`);
|
|
283
285
|
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
284
286
|
}
|
|
285
287
|
|
package/src/replace-match.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as Diff from "diff";
|
|
|
4
4
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
6
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
7
|
-
import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
7
|
+
import { fmtRegion, MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
8
8
|
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
9
9
|
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
10
10
|
import { loadP, loadGuidelines } from "./prompts";
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
type ReplaceMatchReq,
|
|
17
17
|
} from "./payload-contract";
|
|
18
18
|
import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
19
|
-
import { toLF } from "./normalize";
|
|
19
|
+
import { toLF, type LineEnding } from "./normalize";
|
|
20
20
|
import { isFidelitySensitiveChar } from "./edit-fidelity";
|
|
21
|
-
import { countNewlines } from "./line-endings";
|
|
21
|
+
import { countNewlines, splitWithEndings } from "./line-endings";
|
|
22
22
|
import { MAX_RANGE_STALE_LINES } from "./constants";
|
|
23
23
|
import {
|
|
24
24
|
assertBoundaryLinesServed,
|
|
@@ -44,6 +44,7 @@ interface MatchRefs {
|
|
|
44
44
|
export interface MatchPlan {
|
|
45
45
|
editParams: { remove_from: string; remove_to: string; text: string };
|
|
46
46
|
warnings: string[];
|
|
47
|
+
contentSeparators: (LineEnding | undefined)[];
|
|
47
48
|
servedOverride?: ReadonlyMap<string, string>;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -51,18 +52,46 @@ function formatMatchRange(start: number, end: number): string {
|
|
|
51
52
|
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function replaceAllOccurrences(
|
|
55
|
-
|
|
55
|
+
function replaceAllOccurrences(
|
|
56
|
+
text: string,
|
|
57
|
+
oldText: string,
|
|
58
|
+
newText: string,
|
|
59
|
+
): { text: string; separators: (LineEnding | undefined)[]; count: number } {
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
const inserted: Array<{ start: number; end: number }> = [];
|
|
62
|
+
let length = 0;
|
|
63
|
+
const append = (chunk: string, fromNewText: boolean): void => {
|
|
64
|
+
if (fromNewText) inserted.push({ start: length, end: length + chunk.length });
|
|
65
|
+
parts.push(chunk);
|
|
66
|
+
length += chunk.length;
|
|
67
|
+
};
|
|
56
68
|
let cursor = 0;
|
|
57
69
|
let count = 0;
|
|
58
70
|
for (;;) {
|
|
59
71
|
const found = text.indexOf(oldText, cursor);
|
|
60
72
|
if (found < 0) break;
|
|
61
|
-
|
|
73
|
+
append(text.slice(cursor, found), false);
|
|
74
|
+
append(newText, true);
|
|
62
75
|
cursor = found + oldText.length;
|
|
63
76
|
count += 1;
|
|
64
77
|
}
|
|
65
|
-
|
|
78
|
+
if (count === 0) return { text, separators: [], count: 0 };
|
|
79
|
+
append(text.slice(cursor), false);
|
|
80
|
+
const replacement = parts.join("");
|
|
81
|
+
const parsed = splitWithEndings(replacement);
|
|
82
|
+
const separators: (LineEnding | undefined)[] = [];
|
|
83
|
+
let offset = 0;
|
|
84
|
+
let insertedIndex = 0;
|
|
85
|
+
for (let index = 0; index < parsed.lines.length; index++) {
|
|
86
|
+
offset += parsed.lines[index]!.length;
|
|
87
|
+
const ending = parsed.endings[index];
|
|
88
|
+
if (ending === undefined) continue;
|
|
89
|
+
while (insertedIndex < inserted.length && inserted[insertedIndex]!.end <= offset) insertedIndex += 1;
|
|
90
|
+
const fromNewText = insertedIndex < inserted.length && offset >= inserted[insertedIndex]!.start && offset < inserted[insertedIndex]!.end;
|
|
91
|
+
separators.push(fromNewText ? ending : undefined);
|
|
92
|
+
offset += ending.length;
|
|
93
|
+
}
|
|
94
|
+
return { text: replacement, separators, count };
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
const RETYPE_DIFF_MAX_CHARS = 20_000;
|
|
@@ -206,10 +235,11 @@ export function buildReplaceMatchEdit(
|
|
|
206
235
|
const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
|
|
207
236
|
const oldText = toLF(req.old_string);
|
|
208
237
|
const newText = toLF(req.new_string);
|
|
209
|
-
const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
|
|
238
|
+
const { text: replacement, separators: replacementSeparators, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
|
|
210
239
|
if (count === 0) {
|
|
211
240
|
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
|
|
212
241
|
}
|
|
242
|
+
const contentSeparators = parsePayloadText(replacement).lines.map((_, index) => replacementSeparators[index]);
|
|
213
243
|
const startRef = fromLine <= toLine ? refs.from : refs.to;
|
|
214
244
|
const endRef = fromLine <= toLine ? refs.to : refs.from;
|
|
215
245
|
const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
|
|
@@ -228,6 +258,7 @@ export function buildReplaceMatchEdit(
|
|
|
228
258
|
text: replacement,
|
|
229
259
|
},
|
|
230
260
|
warnings,
|
|
261
|
+
contentSeparators,
|
|
231
262
|
...(servedOverride !== undefined ? { servedOverride } : {}),
|
|
232
263
|
};
|
|
233
264
|
}
|
|
@@ -251,6 +282,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
|
|
|
251
282
|
return {
|
|
252
283
|
hedit,
|
|
253
284
|
warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
|
|
285
|
+
contentSeparators: matchPlan.contentSeparators,
|
|
254
286
|
planned,
|
|
255
287
|
foldedLines: 0,
|
|
256
288
|
...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
|
|
@@ -286,6 +318,7 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
|
|
|
286
318
|
noPersist: true,
|
|
287
319
|
preloadedNorm: preload,
|
|
288
320
|
served: plan.servedOverride,
|
|
321
|
+
endingOverrides: plan.contentSeparators,
|
|
289
322
|
signal,
|
|
290
323
|
});
|
|
291
324
|
return previewFromPipe(pipe);
|
|
@@ -367,6 +400,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
367
400
|
signal,
|
|
368
401
|
preloadedNorm: preload,
|
|
369
402
|
served: plan.servedOverride,
|
|
403
|
+
endingOverrides: plan.contentSeparators,
|
|
370
404
|
});
|
|
371
405
|
return commitEdit(pipe, {
|
|
372
406
|
path: pipe.path,
|
package/src/replace.ts
CHANGED
|
@@ -158,9 +158,11 @@ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[]
|
|
|
158
158
|
const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
|
|
159
159
|
const separators: (LineEnding | undefined)[] = new Array(length);
|
|
160
160
|
for (let index = 0; index < length; index++) {
|
|
161
|
-
separators[index] = overrides[index]
|
|
161
|
+
separators[index] = index < overrides.length ? overrides[index] : edit.content_separators?.[index];
|
|
162
|
+
}
|
|
163
|
+
if (separators.every((ending) => ending === undefined)) {
|
|
164
|
+
return edit.content_separators === undefined ? edit : { ...edit, content_separators: undefined };
|
|
162
165
|
}
|
|
163
|
-
if (separators.every((ending) => ending === undefined)) return edit;
|
|
164
166
|
return { ...edit, content_separators: separators };
|
|
165
167
|
}
|
|
166
168
|
|
package/tool-prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines
|
|
1
|
+
Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines from that anchor row onward — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|