pi-hashline-edit-pro 0.13.3 → 0.13.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
@@ -1,8 +1,8 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 3-character content hashes over a 64-character URL-safe base64 alphabet give 18 bits of entropy per anchor, with perfect hashing (collision resolution) ensuring every line gets a unique anchor.
3
+ A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. Every line gets a unique content hash, so edits stay precise and stale anchors are caught before they reach the file.
4
4
 
5
- Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design in two ways: a 3-character hash length with a 64-character alphabet for more entropy, and perfect hashing (collision resolution) that ensures every line in a file gets a unique anchor.
5
+ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design with 3-character hashes and collision resolution for unique per-line anchors.
6
6
 
7
7
  Every line returned by `read` carries a short content hash. Edits reference those hashes instead of raw text, so the tool can detect stale context and reject outdated changes before they reach the file.
8
8
 
@@ -12,8 +12,8 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
12
12
 
13
13
  This fork makes two changes that compound:
14
14
 
15
- 1. **Bump hash length to 3 characters** of the 64-char URL-safe base64 alphabet. That gives 18 bits / 262,144 buckets, up from 8 bits / 256 in the upstream.
16
- 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `import` statements, repeated `}`) get different hashes automatically.
15
+ 1. **3-character hash length** over a 64-char URL-safe base64 alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 262,144 buckets.
16
+ 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
17
17
 
18
18
  ## Installation
19
19
 
@@ -49,7 +49,7 @@ szJ│ console.log("world");
49
49
  _zl│}
50
50
  ```
51
51
 
52
- - `HASH` is a 3-character content hash from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3`).
52
+ - `HASH` is a 3-character content hash from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3`). See [Hashing](#hashing) for details.
53
53
 
54
54
  Optional parameters:
55
55
 
@@ -82,7 +82,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
82
82
 
83
83
  ### Chained edits
84
84
 
85
- After a successful replace, the response text is empty (warnings are still shown if present). To get fresh anchors for follow-up edits, call `read` on the file first. This avoids token overhead from re-displaying content the model already knows.
85
+ After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present). To get fresh anchors for follow-up edits, call `read` on the file first. This avoids token overhead from re-displaying content the model already knows.
86
86
 
87
87
  ### Auto-read after write
88
88
 
@@ -122,7 +122,7 @@ The runtime always precomputes the full per-line hash array for a file via `line
122
122
 
123
123
  ### Bare-prefix detector
124
124
 
125
- With the `│` delimiter format, the bare-prefix detector regex `^\s*[A-Za-z0-9_\-]{3}│` is highly specific. It only matches lines starting with exactly 3 base64 chars and `│`. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
125
+ With the `│` delimiter format, the bare-prefix detector regex `^\s*[A-Za-z0-9_\-]{3}│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
126
126
 
127
127
  ## Development
128
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -92,11 +92,13 @@ function resToSpan(
92
92
  return null;
93
93
  }
94
94
 
95
+ const label = descEdit(edit);
96
+
95
97
  if (edit.content_lines.length > 0) {
96
98
  return {
97
99
  kind: "replace",
98
100
  index,
99
- label: descEdit(edit),
101
+ label,
100
102
  start: lineStarts[startLine - 1]!,
101
103
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
102
104
  replacement: edit.content_lines.join("\n"),
@@ -107,7 +109,7 @@ function resToSpan(
107
109
  return {
108
110
  kind: "replace",
109
111
  index,
110
- label: descEdit(edit),
112
+ label,
111
113
  start: 0,
112
114
  end: content.length,
113
115
  replacement: "",
@@ -118,7 +120,7 @@ function resToSpan(
118
120
  return {
119
121
  kind: "replace",
120
122
  index,
121
- label: descEdit(edit),
123
+ label,
122
124
  start: lineStarts[startLine - 1]!,
123
125
  end: lineStarts[endLine]!,
124
126
  replacement: "",
@@ -128,7 +130,7 @@ function resToSpan(
128
130
  return {
129
131
  kind: "replace",
130
132
  index,
131
- label: descEdit(edit),
133
+ label,
132
134
  start: Math.max(0, lineStarts[startLine - 1]! - 1),
133
135
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
134
136
  replacement: "",
@@ -27,6 +27,32 @@ function coerceEditArray(items: unknown[]): unknown[] {
27
27
  });
28
28
  }
29
29
 
30
+ /**
31
+ * Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
32
+ * single objects → wrapped in array. Shared by the `changes` and `edits`
33
+ * normalization branches.
34
+ */
35
+ function normalizeField(
36
+ record: Record<string, unknown>,
37
+ from: string,
38
+ to: string,
39
+ ): void {
40
+ if (!has(record, from)) return;
41
+ const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
42
+ if (Array.isArray(raw)) {
43
+ record[to] = coerceEditArray(raw);
44
+ } else {
45
+ const single =
46
+ typeof raw === "string"
47
+ ? tryParseJSON(raw, isRec)
48
+ : isRec(raw)
49
+ ? raw
50
+ : undefined;
51
+ if (single) record[to] = coerceEditArray([single]);
52
+ }
53
+ if (from !== to) delete record[from];
54
+ }
55
+
30
56
  export function normReq(input: unknown): unknown {
31
57
  if (!isRec(input)) {
32
58
  return input;
@@ -39,32 +65,8 @@ export function normReq(input: unknown): unknown {
39
65
  delete record.file_path;
40
66
  }
41
67
 
42
- const hasChangesField = has(record, "changes");
43
- const hasEditsField = has(record, "edits");
44
-
45
- if (hasChangesField) {
46
- const raw = tryParseJSON(record.changes, Array.isArray) ?? record.changes;
47
- if (Array.isArray(raw)) {
48
- record.changes = coerceEditArray(raw);
49
- } else {
50
- // Single object (JSON string or literal) → wrap in array
51
- const single = typeof raw === "string"
52
- ? tryParseJSON(raw, isRec)
53
- : isRec(raw) ? raw : undefined;
54
- if (single) record.changes = coerceEditArray([single]);
55
- }
56
- } else if (hasEditsField) {
57
- const raw = tryParseJSON(record.edits, Array.isArray) ?? record.edits;
58
- if (Array.isArray(raw)) {
59
- record.changes = coerceEditArray(raw);
60
- } else {
61
- const single = typeof raw === "string"
62
- ? tryParseJSON(raw, isRec)
63
- : isRec(raw) ? raw : undefined;
64
- if (single) record.changes = coerceEditArray([single]);
65
- }
66
- delete record.edits;
67
- }
68
+ normalizeField(record, "changes", "changes");
69
+ normalizeField(record, "edits", "changes");
68
70
 
69
71
  return record;
70
72
  }
@@ -142,7 +142,7 @@ export function buildNoop(input: NoopInput): TResult {
142
142
  }
143
143
 
144
144
  export function buildChanged(input: SuccessInput): TResult {
145
- const { result, warnings, snapshotId, originalNormalized, editMeta } = input;
145
+ const { path, result, warnings, snapshotId, originalNormalized, editMeta } = input;
146
146
 
147
147
  const resultLines = visLines(result);
148
148
  const resultHashes = input.resultHashes ?? lineHashes(result);
@@ -150,9 +150,12 @@ export function buildChanged(input: SuccessInput): TResult {
150
150
  const addedLines = cntDiff(diffResult.diff, "+");
151
151
  const removedLines = cntDiff(diffResult.diff, "-");
152
152
  const warningsBlock = warnBlock(warnings);
153
+ const successPrefix = `Successfully replaced in ${path}.`;
153
154
  const text = resultLines.length === 0
154
155
  ? "File is empty. Use replace to insert content."
155
- : warningsBlock.trimStart();
156
+ : warningsBlock
157
+ ? `${successPrefix}${warningsBlock}`
158
+ : successPrefix;
156
159
 
157
160
  const metrics = buildM({
158
161
  classification: "applied",