pi-hashline-edit-pro 4.3.7 → 4.3.9

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
@@ -73,7 +73,7 @@ Edge cases:
73
73
  | --- | --- |
74
74
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
75
75
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
76
- | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. Never embed `\n` inside an element. |
76
+ | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. Never embed `\n` inside an element. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
77
77
 
78
78
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
79
79
 
@@ -89,7 +89,7 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
89
89
 
90
90
  The request is checked before any file I/O, so a bad request never touches the file.
91
91
 
92
- Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, a JSON-array wrapper, and embedded newlines are corrected silently. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config`: `on` strips with a warning, `off` applies edits literally, and `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped.
92
+ Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines are corrected silently. With Boundary dedup `on`, new lines that re-include a block adjacent to the range are stripped when that block is unique in the file, and the whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config` and is `off` by default: `on` strips with a warning, `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped, and `off` applies edits literally.
93
93
  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 `replace`'s `replacement_lines` and `insert`'s `lines`.
94
94
 
95
95
  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, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
@@ -110,7 +110,7 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
110
110
  | --- | --- |
111
111
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
112
112
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
113
- | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line, and never embed `\n` inside an element. |
113
+ | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line, and never embed `\n` inside an element. A lone string is split on newlines, and stringified array text is unwrapped. |
114
114
 
115
115
  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`. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
116
116
 
@@ -197,7 +197,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
197
197
  "anchorGrepEnabled": true,
198
198
  "requirePath": false,
199
199
  "strictInput": false,
200
- "boundaryDedupMode": "on",
200
+ "boundaryDedupMode": "off",
201
201
  "diffContextLines": 1
202
202
  }
203
203
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.7",
3
+ "version": "4.3.9",
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/config-ui.ts CHANGED
@@ -25,7 +25,7 @@ export function configRows(config: Config): ConfigRow[] {
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
27
27
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
28
- { key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "on") !== "off", mode: config.boundaryDedupMode ?? "on", cycle: ["on", "strict", "off"] },
28
+ { key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "off") !== "off", mode: config.boundaryDedupMode ?? "off", cycle: ["on", "strict", "off"] },
29
29
  ];
30
30
  }
31
31
 
package/src/config.ts CHANGED
@@ -29,7 +29,7 @@ const DEFAULT_CONFIG: Config = {
29
29
  autoReadAllIgnore: [],
30
30
  requirePath: false,
31
31
  strictInput: false,
32
- boundaryDedupMode: "on",
32
+ boundaryDedupMode: "off",
33
33
  diffContextLines: DEFAULT_DIFF_CONTEXT_LINES
34
34
  };
35
35
 
@@ -39,7 +39,7 @@ function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMo
39
39
  if (mode === "on" || mode === "strict" || mode === "off") return mode;
40
40
  if (legacy === true) return "on";
41
41
  if (legacy === false) return "off";
42
- return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
42
+ return DEFAULT_CONFIG.boundaryDedupMode ?? "off";
43
43
  }
44
44
 
45
45
  function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
@@ -227,10 +227,10 @@ export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
227
227
  export const toggleRequirePath = (): Promise<boolean> => toggleFlag("requirePath");
228
228
  export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput");
229
229
  export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
230
- let next: BoundaryDedupMode = "on";
230
+ let next: BoundaryDedupMode = "off";
231
231
  await updateConfig((c) => {
232
- const current = c.boundaryDedupMode ?? "on";
233
- next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
232
+ const current = c.boundaryDedupMode ?? "off";
233
+ next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "off";
234
234
  c.boundaryDedupMode = next;
235
235
  });
236
236
  return next;
@@ -19,7 +19,7 @@ export interface EditToolFlags {
19
19
  export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
20
20
  requirePath: false,
21
21
  strictInput: false,
22
- boundaryDedupMode: "on",
22
+ boundaryDedupMode: "off",
23
23
  autoRead: true,
24
24
  autoReadAllActive: false
25
25
  };
@@ -29,7 +29,7 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
29
29
  return {
30
30
  requirePath: config.requirePath === true,
31
31
  strictInput: config.strictInput === true,
32
- boundaryDedupMode: config.boundaryDedupMode ?? "on",
32
+ boundaryDedupMode: config.boundaryDedupMode ?? "off",
33
33
  autoRead: config.autoRead !== false,
34
34
  autoReadAllActive: (config.autoReadAll ?? "off") !== "off"
35
35
  };
@@ -57,6 +57,9 @@ export function withReplacePrompts(base: { description: string; snippet: string;
57
57
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
58
58
  guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
59
59
  }
60
+ if (flags.boundaryDedupMode !== "on") {
61
+ guidelines = guidelines.filter((guideline) => !guideline.includes("deduplicated automatically"));
62
+ }
60
63
  if (flags.boundaryDedupMode === "off") {
61
64
  descriptionParts.push("Boundary dedup is off: edits apply literally.");
62
65
  guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
@@ -170,7 +173,7 @@ export async function throwIfStrictInput(warnings: string[]): Promise<void> {
170
173
  }
171
174
 
172
175
  export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
173
- return (await readConfig()).boundaryDedupMode ?? "on";
176
+ return (await readConfig()).boundaryDedupMode ?? "off";
174
177
  }
175
178
 
176
179
  export function editRenderCallWrapper(
package/src/replace.ts CHANGED
@@ -324,7 +324,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
324
324
  signal,
325
325
  hedit: built.edit,
326
326
  extraWarnings: built.warnings,
327
- skipBoundaryDedup: false,
327
+ skipBoundaryDedup: dedupMode === "off",
328
328
  strictBoundaryDedup,
329
329
  });
330
330
  });
package/src/utils.ts CHANGED
@@ -36,6 +36,7 @@ export function normalizeRequest(input: unknown): unknown {
36
36
  const record: Record<string, unknown> = { ...input };
37
37
  normalizeFilePath(record);
38
38
  normalizeAnchors(record);
39
+ normalizeEditLines(record);
39
40
  return record;
40
41
  }
41
42
 
@@ -211,6 +212,47 @@ function stripCodeFence(text: string): string {
211
212
  return fenced ? fenced[1]!.trim() : trimmed;
212
213
  }
213
214
 
215
+ function arrayLiteralEnd(text: string): number {
216
+ let depth = 0;
217
+ let quote: string | undefined;
218
+ let escaped = false;
219
+ for (let index = 0; index < text.length; index++) {
220
+ const char = text[index]!;
221
+ if (escaped) {
222
+ escaped = false;
223
+ continue;
224
+ }
225
+ if (char === "\\") {
226
+ escaped = true;
227
+ continue;
228
+ }
229
+ if (quote !== undefined) {
230
+ if (char === quote) quote = undefined;
231
+ continue;
232
+ }
233
+ if (char === '"' || char === "'") {
234
+ quote = char;
235
+ continue;
236
+ }
237
+ if (char === "[") depth += 1;
238
+ else if (char === "]") {
239
+ depth -= 1;
240
+ if (depth === 0) return index + 1;
241
+ }
242
+ }
243
+ return -1;
244
+ }
245
+
246
+ function stripTrailingMemberCall(text: string): string {
247
+ if (!text.startsWith("[")) return text;
248
+ const end = arrayLiteralEnd(text);
249
+ if (end < 0) return text;
250
+ const rest = text.slice(end);
251
+ if (rest.trim().length === 0) return text;
252
+ if (!rest.trimStart().startsWith(".") || !rest.trimEnd().endsWith(")")) return text;
253
+ return text.slice(0, end);
254
+ }
255
+
214
256
  function jsonStringArray(text: string): string[] | undefined {
215
257
  try {
216
258
  const parsed: unknown = JSON.parse(text);
@@ -288,7 +330,7 @@ function scanArrayText(inner: string): string[] | undefined {
288
330
 
289
331
  function decodeArrayText(value: unknown): string[] | undefined {
290
332
  if (typeof value !== "string") return undefined;
291
- const trimmed = stripCodeFence(value);
333
+ const trimmed = stripTrailingMemberCall(stripCodeFence(value));
292
334
  if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
293
335
  const decoded = jsonStringArray(trimmed);
294
336
  if (decoded !== undefined && decoded.length > 0) return decoded;
@@ -298,8 +340,8 @@ function decodeArrayText(value: unknown): string[] | undefined {
298
340
 
299
341
  function looksLikeStringArray(value: unknown): boolean {
300
342
  if (typeof value !== "string") return false;
301
- const trimmed = stripCodeFence(value);
302
- return trimmed.endsWith("]") && /^\[\s*['"]/.test(trimmed);
343
+ const trimmed = stripTrailingMemberCall(stripCodeFence(value));
344
+ return /^\[\s*['"]/.test(trimmed) && !trimmed.endsWith("].");
303
345
  }
304
346
 
305
347
  export function decodeStringArray(value: unknown, warnings?: string[], label = "replacement_lines"): string[] | undefined {
@@ -318,3 +360,28 @@ export function decodeStringArray(value: unknown, warnings?: string[], label = "
318
360
  }
319
361
  return undefined;
320
362
  }
363
+
364
+ function splitEditLines(text: string): string[] {
365
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
366
+ }
367
+
368
+ function normalizeLineFieldValue(value: unknown): string[] | undefined {
369
+ const candidate = typeof value === "string"
370
+ ? value
371
+ : Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
372
+ ? value[0]
373
+ : undefined;
374
+ if (candidate === undefined) return undefined;
375
+ const decoded = decodeStringArray(candidate);
376
+ if (decoded !== undefined) return decoded;
377
+ if (/^\[\s*\]$/.test(stripTrailingMemberCall(stripCodeFence(candidate)))) return [];
378
+ return splitEditLines(candidate);
379
+ }
380
+
381
+ function normalizeEditLines(record: Record<string, unknown>): void {
382
+ for (const key of ["replacement_lines", "lines"]) {
383
+ if (!(key in record)) continue;
384
+ const lines = normalizeLineFieldValue(record[key]);
385
+ if (lines !== undefined) record[key] = lines;
386
+ }
387
+ }