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 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 after it. Anchor offsets are stable across edits and return the same editable rows.
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: a reversed range, 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: 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.
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
- pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
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 (_event, ctx) => {
200
+ pi.on("session_shutdown", async () => {
195
201
  try {
196
- const key = sessionKeyFor(ctx);
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",
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",
@@ -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,
@@ -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
 
@@ -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(text: string, oldText: string, newText: string): { text: string; count: number } {
55
- let out = "";
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
- out += text.slice(cursor, found) + newText;
73
+ append(text.slice(cursor, found), false);
74
+ append(newText, true);
62
75
  cursor = found + oldText.length;
63
76
  count += 1;
64
77
  }
65
- return count === 0 ? { text, count: 0 } : { text: out + text.slice(cursor), count };
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] ?? edit.content_separators?.[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
 
@@ -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 after it — 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.
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.