pi-hashline-edit-pro 2.8.3 → 3.0.0

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.
@@ -7,36 +7,20 @@ import {
7
7
  persistSnapshot,
8
8
  } from "../hash-store";
9
9
  import { xxh32, initHasher } from "./hasher";
10
- import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
11
- export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
10
+ import { HASH_LEN, ANCHOR_COUNT, anchorAt, anchorIndex, HASH_CLASS, HASH_RUN } from "./alphabet";
11
+ export { initHasher, HASH_LEN, HASH_CLASS, HASH_RUN };
12
12
 
13
13
  export const ANCHOR_LEN = HASH_LEN;
14
14
 
15
15
  export const HASH_SEP = "│";
16
16
 
17
- export const HASH_SPACE = ALPH.length ** HASH_LEN;
17
+ export const HASH_SPACE = ANCHOR_COUNT;
18
18
  export const MAX_HASH_LINES = HASH_SPACE;
19
19
 
20
- export const HASH_PROBE_STRIDE = ALPH.length ** 2 + ALPH.length + 1;
21
-
22
- function idxToHash(idx: number): string {
23
- let out = "";
24
- for (let j = 0; j < HASH_LEN; j++) {
25
- out = ALPH[idx % ALPH.length]! + out;
26
- idx = Math.floor(idx / ALPH.length);
27
- }
28
- return out;
29
- }
30
-
31
- const hashCache = new Map<number, string>();
20
+ export const HASH_PROBE_STRIDE = 571;
32
21
 
33
22
  function hashAt(idx: number): string {
34
- let hash = hashCache.get(idx);
35
- if (hash === undefined) {
36
- hash = idxToHash(idx);
37
- hashCache.set(idx, hash);
38
- }
39
- return hash;
23
+ return anchorAt(idx);
40
24
  }
41
25
 
42
26
  export const HL_PREFIX_PLUS_RE = new RegExp(
@@ -182,13 +166,7 @@ export async function lineHashes(
182
166
  }
183
167
 
184
168
  function hashToIndex(hash: string): number {
185
- let idx = 0;
186
- for (let j = 0; j < HASH_LEN; j++) {
187
- const charIdx = ALPH.indexOf(hash[j]!);
188
- if (charIdx < 0) return -1;
189
- idx = idx * ALPH.length + charIdx;
190
- }
191
- return idx;
169
+ return anchorIndex(hash);
192
170
  }
193
171
 
194
172
  function nearestNew(
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  ANCHOR_LEN,
3
- ALPH_RE,
4
3
  HASH_CLASS,
5
4
  } from "./hash";
5
+ import { ALPH_RE } from "./alphabet";
6
6
  import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
7
7
 
8
8
  const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
@@ -13,11 +13,11 @@ function diagRef(ref: string): string {
13
13
  const trimmed = ref.trim();
14
14
 
15
15
  if (!trimmed.length) {
16
- return `[E_BAD_REF] Invalid anchor. Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
16
+ return `[E_BAD_REF] Invalid anchor. Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
17
17
  }
18
18
 
19
19
  if (/^\d+/.test(trimmed)) {
20
- return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
20
+ return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "Hasu"): no line numbers or trailing content.`;
21
21
  }
22
22
  if (trimmed.includes("│") && trimmed.includes("\n")) {
23
23
  const lines = trimmed.split(/\r?\n/);
@@ -26,16 +26,16 @@ function diagRef(ref: string): string {
26
26
  const hashRe = HASH_EXTRACT_RE;
27
27
  const firstMatch = first.match(hashRe);
28
28
  const lastMatch = last.match(hashRe);
29
- const firstHash = firstMatch?.[0] ?? "aB3";
30
- const lastHash = lastMatch?.[0] ?? "aB3";
29
+ const firstHash = firstMatch?.[0] ?? "Hasu";
30
+ const lastHash = lastMatch?.[0] ?? "Hasu";
31
31
  const preview = first.slice(0, 60);
32
- return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 3-char hash (e.g. "aB3"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
32
+ return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-char hash (e.g. "Hasu"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
33
33
  }
34
34
  if (trimmed.includes("│")) {
35
- return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
35
+ return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-char anchor, drop everything from "│" onward.`;
36
36
  }
37
37
 
38
- return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
38
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
39
39
  }
40
40
 
41
41
  function parseRef(ref: string): Anchor {
@@ -53,6 +53,23 @@ function parseRef(ref: string): Anchor {
53
53
 
54
54
  export const parseHashRef = parseRef;
55
55
 
56
+ const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
57
+
58
+ function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
59
+ const match = line.match(JSON_ENVELOPE_RE);
60
+ if (!match) return line;
61
+ const withoutDot = line.trim().slice(0, -1);
62
+ try {
63
+ JSON.parse(withoutDot);
64
+ return line;
65
+ } catch {
66
+ warnings?.push(
67
+ '[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
68
+ );
69
+ return match[1]!;
70
+ }
71
+ }
72
+
56
73
  export function parseText(edit: string[], warnings?: string[]): string[] {
57
74
  if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
58
75
  throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
@@ -60,13 +77,14 @@ export function parseText(edit: string[], warnings?: string[]): string[] {
60
77
  const out: string[] = [];
61
78
  let split = false;
62
79
  for (const line of edit) {
63
- const normalized = line.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
64
- if (normalized !== line) split = true;
80
+ const unwrapped = unwrapJsonEnvelope(line, warnings);
81
+ const normalized = unwrapped.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
82
+ if (normalized !== unwrapped) split = true;
65
83
  out.push(...normalized.split("\n"));
66
84
  }
67
85
  if (split) {
68
86
  warnings?.push(
69
- "[E_BAD_SHAPE] Autocorrected: split embedded newlines in replacement_lines into separate lines.",
87
+ "[E_BAD_SHAPE] replacement_lines contained embedded newlines; split into one line each.",
70
88
  );
71
89
  }
72
90
  return out;
@@ -159,12 +159,12 @@ function assertItem(edit: Record<string, unknown>): void {
159
159
 
160
160
  if ("remove_from" in edit && typeof edit.remove_from !== "string") {
161
161
  throw new Error(
162
- `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char anchor).`,
162
+ `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
163
163
  );
164
164
  }
165
165
  if ("remove_to" in edit && typeof edit.remove_to !== "string") {
166
166
  throw new Error(
167
- `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char anchor).`,
167
+ `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
168
168
  );
169
169
  }
170
170
  if (!("replacement_lines" in edit)) {
@@ -175,7 +175,7 @@ function assertItem(edit: Record<string, unknown>): void {
175
175
  }
176
176
  if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
177
177
  throw new Error(
178
- `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char anchors from read output).`,
178
+ `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
179
179
  );
180
180
  }
181
181
  }
@@ -195,7 +195,7 @@ export function stripAnchorRow(
195
195
  : match[1] === "-"
196
196
  ? 'leading "-" marker'
197
197
  : '"anchor│" prefix';
198
- warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
198
+ warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${clipLine(trimmed, 48)}".`);
199
199
  return match[2]!;
200
200
  }
201
201
 
@@ -240,13 +240,8 @@ export function stripBarePrefixes(
240
240
  const locations = stripped
241
241
  .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
242
242
  .join(", ");
243
- const matchedCount = stripped.filter((s) => s.matched).length;
244
- const guidance =
245
- matchedCount === 0
246
- ? " Verify it was pasted from read output."
247
- : "";
248
243
  warnings.push(
249
- `[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.${guidance}`
244
+ `[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.`
250
245
  );
251
246
  return { ...edit, content_lines: contentLines };
252
247
  }
@@ -290,7 +285,7 @@ export function swapReversedRanges(
290
285
  return edit;
291
286
  }
292
287
  warnings.push(
293
- `[E_BAD_OP] Autocorrected: remove_from/remove_to were reversed; swapped them.`
288
+ `[E_BAD_OP] Swapped reversed remove_from/remove_to.`
294
289
  );
295
290
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
296
291
  }
package/src/insert.ts CHANGED
@@ -31,7 +31,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
31
31
  throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
32
32
  }
33
33
  if (typeof request.anchor !== "string" || request.anchor.length === 0) {
34
- throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char anchor from read output).');
34
+ throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
35
35
  }
36
36
  if (request.direction !== "before" && request.direction !== "after") {
37
37
  throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
@@ -49,23 +49,23 @@ const insertToolSchema = Type.Object(
49
49
  }),
50
50
  anchor: Type.String({
51
51
  description:
52
- 'Bare 3-char anchor only (e.g. "aB3"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
52
+ '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.',
53
53
  }),
54
54
  direction: Type.Union(
55
55
  [
56
- Type.Literal("after", { description: "Insert the lines after the anchor line" }),
57
- Type.Literal("before", { description: "Insert the lines before the anchor line" }),
56
+ Type.Literal("after"),
57
+ Type.Literal("before"),
58
58
  ],
59
59
  { description: '"after" or "before"' },
60
60
  ),
61
61
  lines: Type.Array(
62
62
  Type.String({
63
63
  description:
64
- "One line to insert. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
64
+ "One line to insert; never embed \\n inside an element.",
65
65
  }),
66
66
  {
67
67
  description:
68
- 'Lines to insert as an array of strings, one element per line. Use [""] for a blank line. The anchor line is preserved; never include it in lines.',
68
+ 'One string per line; [""] is a blank line; never include the anchor line.',
69
69
  }
70
70
  ),
71
71
  },
@@ -4,22 +4,22 @@ import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
6
6
  description:
7
- "One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
7
+ "One replacement line; never embed \\n inside an element.",
8
8
  }),
9
9
  {
10
10
  description:
11
- "Replacement lines as an array of strings, one element per line. Use [] to delete the range.",
11
+ "One string per line. Use [] to delete the range.",
12
12
  },
13
13
  );
14
14
 
15
15
  const removeFromSchema = Type.String({
16
16
  description:
17
- "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
17
+ "Bare 4-char anchor from a read row like `Hasu│content` (the leftmost column), never the row content. Marks the FIRST line to remove (inclusive)",
18
18
  });
19
19
 
20
20
  const removeToSchema = Type.String({
21
21
  description:
22
- "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
22
+ "Bare 4-char anchor from a read row like `Hasu│content` (the leftmost column), never the row content. Marks the LAST line to remove (inclusive)",
23
23
  });
24
24
 
25
25
  export const editToolSchema = Type.Object(
@@ -27,7 +27,7 @@ export const editToolSchema = Type.Object(
27
27
  path: Type.Optional(
28
28
  Type.String({
29
29
  description:
30
- "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake.",
30
+ "Path to edit; always provide it explicitly — it is only auto-resolved from the anchors as a fallback.",
31
31
  }),
32
32
  ),
33
33
  remove_from: removeFromSchema,
@@ -100,7 +100,7 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
100
100
  : "The edit produced identical content.";
101
101
  const dedupNote =
102
102
  boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
103
- ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) from the replacement. Send the same edit again to apply it literally.`
103
+ ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s); resend the same edit to apply it literally.`
104
104
  : "";
105
105
 
106
106
  const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
@@ -146,7 +146,7 @@ export function regUndo(pi: ExtensionAPI): void {
146
146
  content: [
147
147
  {
148
148
  type: "text",
149
- text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. The undo record is kept; once the file matches the edited state again, undo_last_change will succeed. Call read() to inspect the current state.`
149
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted. The current content already contains your applied edit plus that external change and is most likely the correct state. Do not modify the file to make an undo possible and do not revert your own edit. The undo record is kept. Call read() to verify the current state, then stop.`
150
150
  },
151
151
  ],
152
152
  isError: true,
package/src/replace.ts CHANGED
@@ -63,6 +63,7 @@ export interface PipelineResult {
63
63
  totalRemovedLines: number;
64
64
  hadBoundaryDedup: boolean;
65
65
  boundaryRemovedLines: number;
66
+ boundaryRemovedLineTexts: string[];
66
67
  identity: FileIdentity;
67
68
  }
68
69
 
@@ -91,7 +92,7 @@ async function resolveMissingPath(
91
92
  if (matches.length === 1) {
92
93
  return {
93
94
  path: matches[0]!,
94
- warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]}.`,
95
+ warning: `[E_BAD_SHAPE] Missing "path" resolved to ${matches[0]}.`,
95
96
  };
96
97
  }
97
98
  if (matches.length > 1) {
@@ -226,6 +227,7 @@ export async function execPipeline(
226
227
  totalRemovedLines,
227
228
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
228
229
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
230
+ boundaryRemovedLineTexts: anchorResult.autoFixes?.map((fix) => fix.removedLine) ?? [],
229
231
  identity,
230
232
  };
231
233
  }
package/src/utils.ts CHANGED
@@ -114,7 +114,7 @@ export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V):
114
114
  }
115
115
 
116
116
  export function isHashRow(line: string): boolean {
117
- return /^[A-Za-z0-9]{3}│/.test(line);
117
+ return /^[A-Za-z0-9]{4}│/.test(line);
118
118
  }
119
119
 
120
120
  function gutterWidth(max: number, fallback: number): number {