pi-hashline-edit-pro 1.2.0 → 2.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.
package/README.md CHANGED
@@ -42,8 +42,8 @@ kQm│}
42
42
  ```json
43
43
  {
44
44
  "path": "src/main.ts",
45
- "hash_range_inclusive": ["szJ", "szJ"],
46
- "content_lines": [" console.log('hi');"]
45
+ "hash_bounds": ["szJ", "szJ"],
46
+ "new_content": " console.log('hi');"
47
47
  }
48
48
  ```
49
49
 
@@ -77,31 +77,30 @@ Edge cases:
77
77
 
78
78
  The built-in `edit` tool is disabled — `replace` is the only edit path; call it with the hash anchors from `read` output.
79
79
 
80
- Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at the top level of the request:
80
+ Exactly one edit per call, with `hash_bounds` and `new_content` at the top level of the request:
81
81
 
82
82
  ```json
83
83
  {
84
84
  "path": "src/main.ts",
85
- "hash_range_inclusive": ["szJ", "kQm"],
86
- "content_lines": [" console.log('hi');", "}"]
85
+ "hash_bounds": ["szJ", "kQm"],
86
+ "new_content": " console.log('hi');\n}"
87
87
  }
88
88
  ```
89
89
 
90
90
  | Field | Description |
91
91
  | --- | --- |
92
- | `hash_range_inclusive` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
93
- | `content_lines` | Replacement content, one string per line; entries must not contain line breaks. Use `[]` to delete the range. |
92
+ | `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
93
+ | `new_content` | Replacement content as a single string with `\n` line separators; a trailing newline is the last line's ending, not an extra empty line. Use `""` to delete the range. |
94
94
 
95
95
  Behavior:
96
96
 
97
97
  - **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
98
- - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
99
98
  - **Autocorrections** (all accompanied by a warning unless noted):
100
- - A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
101
- - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
99
+ - A `HASH│` prefix accidentally left on a `new_content` line is stripped.
100
+ - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `new_content` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
102
101
  - A reversed range (start hash after end hash) is swapped and applied.
103
102
  - A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
104
- - `file_path` is accepted as an alias for `path`; a JSON-string `content_lines` is parsed into an array.
103
+ - `file_path` is accepted as an alias for `path`.
105
104
  - **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it — and reaches the model-visible text only while auto-read is on.
106
105
  - **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
107
106
 
@@ -121,7 +120,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
121
120
  Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
122
121
 
123
122
  - A no-op `replace` produces no diff — the file is unchanged, so existing anchors remain valid.
124
- - After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — call `read` for fresh anchors.
123
+ - After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — the diff rows themselves are the fresh anchors (`+HASH│` and ` HASH│` rows carry the current hashes, and unchanged lines keep their previous hashes), so follow-up edits can anchor on the diff directly; call `read` when you want the full file's anchors.
125
124
  - With auto-read disabled, `replace` / `undo_last_replace` results keep the plain summary in the model-visible text — no diff and no anchor block reach the model (the post-edit diff is still shown to the user).
126
125
  - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
127
126
  - Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
@@ -157,13 +156,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
157
156
 
158
157
  | Code | Meaning |
159
158
  | --- | --- |
160
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields, or a `content_lines` entry contains a line break. |
161
- | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
159
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (e.g. `new_content` must be a string with `\n` line separators). |
160
+ | `[E_BAD_REF]` | An anchor in `hash_bounds` is not a bare 3-char hash. |
162
161
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
163
162
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
164
- | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
165
- | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
166
- | `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
163
+ | `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
164
+ | `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
167
165
  | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
168
166
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
169
167
  | `[E_NOT_FOUND]` | The path does not exist. |
@@ -187,7 +185,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
187
185
  - **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
188
186
  - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
189
187
  - **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
190
- - **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
188
+ - **One edit per call.** The request shape stays `{path, hash_bounds, new_content}` from schema through validation to application; there is no batching dialect.
191
189
 
192
190
  ## Troubleshooting
193
191
 
@@ -195,7 +193,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
195
193
  - **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
196
194
  - **Upgrading.** A hash-allocation change clears the hash store once on the first run after upgrade — anchors are rebuilt on the next read and undo history is lost, but no project files are touched.
197
195
  - **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
198
- - **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
196
+ - **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted. Legacy snapshots containing duplicate hashes are skipped and rebuilt on the next read.
199
197
  - **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
200
198
 
201
199
  ## Development
package/index.ts CHANGED
@@ -7,6 +7,7 @@ import { regRead, fmtReadPreview } from "./src/read";
7
7
  import type { RMetrics } from "./src/replace-response";
8
8
  import { extractWarnings } from "./src/replace-render";
9
9
  import { MAX_HASH_LINES } from "./src/hashline";
10
+ import { AUTO_READ_MAX } from "./src/constants";
10
11
  import {
11
12
  readConfig,
12
13
  toggleAutoRead,
@@ -81,6 +82,7 @@ export default function (pi: ExtensionAPI): void {
81
82
  fileHashes,
82
83
  absolutePath,
83
84
  DEFAULT_MAX_BYTES,
85
+ AUTO_READ_MAX,
84
86
  );
85
87
  return {
86
88
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -1,2 +1,2 @@
1
1
  - `read`: call before `replace` when you need fresh HASH anchors for a file.
2
- - `read`: call again after any edit to that file — changed lines get new anchors.
2
+ - `read`: call again after any edit to that file — changed lines get new anchors (the post-edit diff rows from replace/undo also carry fresh anchors for the changed range).
@@ -1,6 +1,7 @@
1
- - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
2
- - `replace`: hash_range_inclusive marks the exact lines that are REMOVED, and content_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in content_lines is deleted from the file — including closing braces and other structural lines.
1
+ - `replace`: hash_bounds must use only anchors from the most recent read of the same file.
2
+ - `replace`: hash_bounds marks the exact lines that are REMOVED, and new_content is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in new_content is deleted from the file — including closing braces and other structural lines.
3
3
  - `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
4
- - `replace`: to replace a single line, repeat its hash in both positions of hash_range_inclusive: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
5
- - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
- - `replace`: content_lines entries are single lines never embed a line break inside an entry; pass each line as its own array entry.
4
+ - `replace`: to replace a single line, repeat its hash in both positions of hash_bounds: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
5
+ - `replace`: new_content is a single string with \n line separators — never a JSON array. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
+ - `replace`: a trailing newline in new_content is the last line's ending, not an extra empty line; use an explicit empty line (e.g. ending with \n\n) to add a blank line.
7
+ - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
@@ -1 +1,2 @@
1
- - `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
1
+ - `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
2
+ - `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
package/src/constants.ts CHANGED
@@ -5,6 +5,6 @@ export const MAX_READ_LINE_BYTES = 200 * 1024;
5
5
 
6
6
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
7
7
  export const HASH_STORE_VERSION = 5;
8
- export const CONTENT_LINES_NOT_STRING_MSG =
9
- `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
10
- + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
8
+ export const NEW_CONTENT_NOT_STRING_MSG =
9
+ `[E_BAD_SHAPE] "new_content" must be a string with \\n line separators, not an array.`
10
+ + ` Do not pass an array of lines — pass the replacement text as one string: "line1\\nline2". Use "" to delete a range.`;
package/src/hash-store.ts CHANGED
@@ -318,6 +318,10 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
318
318
  const rows: [string, string, number, string, number][] = [];
319
319
  for (const [key, value] of Object.entries(raw)) {
320
320
  if (!isValidSnapshot(value)) continue;
321
+ if (new Set(value.hashes).size !== value.hashes.length) {
322
+ console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
323
+ continue;
324
+ }
321
325
  rows.push([
322
326
  key,
323
327
  contentChecksum(value.content),
@@ -65,8 +65,8 @@ function resToSpan(
65
65
  ): RESpan | NoopSpan {
66
66
  const { fileLines, lineStarts } = lineIndex;
67
67
 
68
- const startLine = edit.hash_range_inclusive[0].line;
69
- const endLine = edit.hash_range_inclusive[1].line;
68
+ const startLine = edit.hash_bounds[0].line;
69
+ const endLine = edit.hash_bounds[1].line;
70
70
  const originalLines = fileLines.slice(startLine - 1, endLine);
71
71
  if (
72
72
  originalLines.length === edit.content_lines.length &&
@@ -76,7 +76,7 @@ function resToSpan(
76
76
  ) {
77
77
  return {
78
78
  kind: "noop",
79
- loc: edit.hash_range_inclusive[0].hash,
79
+ loc: edit.hash_bounds[0].hash,
80
80
  currentContent: originalLines.join("\n"),
81
81
  };
82
82
  }
@@ -2,7 +2,7 @@ import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
4
  } from "./hash";
5
- import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
5
+ import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
6
6
 
7
7
  export type Anchor = { hash: string };
8
8
 
@@ -18,7 +18,7 @@ function diagRef(ref: string): string {
18
18
  }
19
19
 
20
20
  if (trimmed.includes("│")) {
21
- return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_range_inclusive must contain the 3-char hash only — remove everything from "│" onward.`;
21
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_bounds must contain the 3-char hash only — remove everything from "│" onward.`;
22
22
  }
23
23
 
24
24
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
@@ -39,18 +39,13 @@ function parseRef(ref: string): Anchor {
39
39
 
40
40
  export const parseHashRef = parseRef;
41
41
 
42
- export function parseText(edit: string[] | string | null): string[] {
43
- if (edit === null) {
44
- throw new Error('[E_BAD_SHAPE] "content_lines" must be a string array; use [] to delete a range.');
42
+ export function parseText(edit: string): string[] {
43
+ if (typeof edit !== "string") {
44
+ throw new Error(NEW_CONTENT_NOT_STRING_MSG);
45
45
  }
46
- if (typeof edit === "string") {
47
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
48
- }
49
- const lineBreakIndex = edit.findIndex((line) => /[\r\n]/.test(line));
50
- if (lineBreakIndex >= 0) {
51
- throw new Error(
52
- `[E_BAD_SHAPE] "content_lines" entry at index ${lineBreakIndex} contains a \\r or \\n line break. Pass each line as its own array entry.`,
53
- );
54
- }
55
- return edit;
46
+ const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
47
+ if (normalized === "") return [];
48
+ const lines = normalized.split("\n");
49
+ if (normalized.endsWith("\n")) lines.pop();
50
+ return lines;
56
51
  }
@@ -1,7 +1,7 @@
1
1
  import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
2
2
  import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
- import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
4
+ import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
5
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
@@ -9,10 +9,10 @@ export type RAnchor = {
9
9
  hashMatched: boolean;
10
10
  };
11
11
 
12
- export type HEdit = { content_lines: string[]; hash_range_inclusive: [Anchor, Anchor] };
12
+ export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
13
13
  export type RHEdit = {
14
14
  content_lines: string[];
15
- hash_range_inclusive: [RAnchor, RAnchor];
15
+ hash_bounds: [RAnchor, RAnchor];
16
16
  };
17
17
 
18
18
  interface HMismatch {
@@ -40,8 +40,8 @@ export interface NEdit {
40
40
  }
41
41
 
42
42
  export type HTEdit = {
43
- content_lines: string[];
44
- hash_range_inclusive: [string, string];
43
+ new_content: string;
44
+ hash_bounds: [string, string];
45
45
  };
46
46
 
47
47
  function resAnchorFromMap(
@@ -89,7 +89,7 @@ export function fmtMismatch(
89
89
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
90
90
  if (notFound.length > 0) {
91
91
  out.push(
92
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
92
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_bounds of your next replace call.`
93
93
  );
94
94
  for (const m of notFound) {
95
95
  const ctx = m.context;
@@ -107,7 +107,7 @@ export function fmtMismatch(
107
107
  if (ambiguous.length > 0) {
108
108
  if (out.length > 0) out.push("");
109
109
  out.push(
110
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
110
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_bounds of your next replace call.`
111
111
  );
112
112
  for (const m of ambiguous) {
113
113
  const sample = (m.candidates ?? []).slice(0, 5);
@@ -130,13 +130,7 @@ export function fmtMismatch(
130
130
  return out.join("\n");
131
131
  }
132
132
 
133
- const ITEM_KS = new Set(["content_lines", "hash_range_inclusive"]);
134
-
135
- function isStrArr(value: unknown): value is string[] {
136
- return (
137
- Array.isArray(value) && value.every((item) => typeof item === "string")
138
- );
139
- }
133
+ const ITEM_KS = new Set(["new_content", "hash_bounds"]);
140
134
 
141
135
  function isStrPair(value: unknown): value is [string, string] {
142
136
  return (
@@ -147,36 +141,22 @@ function isStrPair(value: unknown): value is [string, string] {
147
141
  }
148
142
 
149
143
  function assertItem(edit: Record<string, unknown>): void {
150
- rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { content_lines, hash_range_inclusive }.");
144
+ rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { new_content, hash_bounds }.");
151
145
 
152
- if ("hash_range_inclusive" in edit && !isStrPair(edit.hash_range_inclusive)) {
146
+ if ("hash_bounds" in edit && !isStrPair(edit.hash_bounds)) {
153
147
  throw new Error(
154
- `[E_BAD_SHAPE] Field "hash_range_inclusive" must be a pair of anchor strings [start, end].`,
148
+ `[E_BAD_SHAPE] Field "hash_bounds" must be a pair of anchor strings [start, end].`,
155
149
  );
156
150
  }
157
- if (!("content_lines" in edit)) {
158
- throw new Error(`[E_BAD_SHAPE] The edit requires a "content_lines" field. Provide the replacement lines (use [] to delete).`);
151
+ if (!("new_content" in edit)) {
152
+ throw new Error(`[E_BAD_SHAPE] The edit requires a "new_content" field. Provide the replacement text (use "" to delete).`);
159
153
  }
160
- if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
161
- const val = edit.content_lines;
162
- if (typeof val === "string") {
163
- try {
164
- const parsed = JSON.parse(val);
165
- if (Array.isArray(parsed)) {
166
- edit.content_lines = parsed;
167
- } else {
168
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
169
- }
170
- } catch {
171
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
172
- }
173
- } else {
174
- throw new Error(`[E_BAD_SHAPE] Field "content_lines" must be a string array.`);
175
- }
154
+ if (typeof edit.new_content !== "string") {
155
+ throw new Error(NEW_CONTENT_NOT_STRING_MSG);
176
156
  }
177
- if (!isStrPair(edit.hash_range_inclusive)) {
157
+ if (!isStrPair(edit.hash_bounds)) {
178
158
  throw new Error(
179
- `[E_BAD_SHAPE] The edit requires a "hash_range_inclusive" pair of anchor strings [start, end].`,
159
+ `[E_BAD_SHAPE] The edit requires a "hash_bounds" pair of anchor strings [start, end].`,
180
160
  );
181
161
  }
182
162
  }
@@ -184,10 +164,10 @@ function assertItem(edit: Record<string, unknown>): void {
184
164
  export function resEdit(edit: HTEdit): HEdit {
185
165
  assertItem(edit as Record<string, unknown>);
186
166
 
187
- const replaceLines = parseText(edit.content_lines);
167
+ const replaceLines = parseText(edit.new_content);
188
168
  return {
189
169
  content_lines: replaceLines,
190
- hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
170
+ hash_bounds: [parseHashRef(edit.hash_bounds[0]), parseHashRef(edit.hash_bounds[1])],
191
171
  };
192
172
  }
193
173
 
@@ -217,15 +197,19 @@ export function stripBarePrefixes(
217
197
  });
218
198
  if (stripped.length === 0) return edit;
219
199
  const locations = stripped
220
- .map((s) => `content_lines[${s.lineIndex}]`)
200
+ .map((s) => `new_content line ${s.lineIndex + 1}`)
221
201
  .join(", ");
222
202
  const matchedCount = stripped.filter((s) => s.matched).length;
223
203
  const evidence =
224
204
  matchedCount === 0
225
205
  ? "none of the stripped hashes match current file lines"
226
206
  : `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
207
+ const guidance =
208
+ matchedCount === 0
209
+ ? " Verify that these lines were pasted from read output; literal content starting with 'HASH│' would be altered by this strip."
210
+ : "";
227
211
  warnings.push(
228
- `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
212
+ `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).${guidance}`
229
213
  );
230
214
  return { ...edit, content_lines: contentLines };
231
215
  }
@@ -249,7 +233,7 @@ export function stripDiffPrefixes(
249
233
  return line;
250
234
  });
251
235
  if (stripped.length === 0) return edit;
252
- const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
236
+ const locations = stripped.map((i) => `new_content line ${i + 1}`).join(", ");
253
237
  warnings.push(
254
238
  `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
255
239
  );
@@ -265,7 +249,7 @@ export function swapReversedRanges(
265
249
  for (let i = 0; i < fileHashes.length; i++) {
266
250
  lineByHash.set(fileHashes[i]!, i + 1);
267
251
  }
268
- const [startRef, endRef] = edit.hash_range_inclusive;
252
+ const [startRef, endRef] = edit.hash_bounds;
269
253
  const startLine = lineByHash.get(startRef.hash);
270
254
  const endLine = lineByHash.get(endRef.hash);
271
255
  if (
@@ -276,9 +260,9 @@ export function swapReversedRanges(
276
260
  return edit;
277
261
  }
278
262
  warnings.push(
279
- `[E_BAD_OP] Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
263
+ `[E_BAD_OP] Autocorrected: hash_bounds was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
280
264
  );
281
- return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
265
+ return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
282
266
  }
283
267
 
284
268
  function checkBoundaryDup(
@@ -330,21 +314,21 @@ export function valEdit(
330
314
  };
331
315
 
332
316
  abortIf(signal);
333
- const startResolved = tryResolve(edit.hash_range_inclusive[0]);
334
- const endResolved = tryResolve(edit.hash_range_inclusive[1]);
317
+ const startResolved = tryResolve(edit.hash_bounds[0]);
318
+ const endResolved = tryResolve(edit.hash_bounds[1]);
335
319
  if (!startResolved || !endResolved) {
336
320
  if (!startResolved && endResolved) {
337
- const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_range_inclusive[0]);
321
+ const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[0]);
338
322
  if (startMismatch && startMismatch.kind === "not_found") startMismatch.context = endResolved;
339
323
  } else if (startResolved && !endResolved) {
340
- const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_range_inclusive[1]);
324
+ const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[1]);
341
325
  if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
342
326
  }
343
327
  return { resolved: undefined, mismatches, boundaryWarnings };
344
328
  }
345
329
  if (startResolved.line > endResolved.line) {
346
330
  throw new Error(
347
- `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_inclusive[0].hash} and ${edit.hash_range_inclusive[1].hash}).`,
331
+ `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_bounds[0].hash} and ${edit.hash_bounds[1].hash}).`,
348
332
  );
349
333
  }
350
334
  const endLine = endResolved.line;
@@ -360,7 +344,7 @@ export function valEdit(
360
344
  return {
361
345
  resolved: {
362
346
  content_lines: edit.content_lines,
363
- hash_range_inclusive: [startResolved, endResolved],
347
+ hash_bounds: [startResolved, endResolved],
364
348
  },
365
349
  mismatches,
366
350
  boundaryWarnings,
package/src/read.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  createReadTool,
4
4
  formatSize,
5
5
  truncateHead,
6
+ DEFAULT_MAX_LINES,
6
7
  type TruncationResult,
7
8
  } from "@earendil-works/pi-coding-agent";
8
9
  import { Type } from "typebox";
@@ -57,6 +58,7 @@ export async function fmtReadPreview(
57
58
  precomputedHashes?: string[],
58
59
  path?: string,
59
60
  maxLineBytes = MAX_READ_LINE_BYTES,
61
+ maxTruncLines = DEFAULT_MAX_LINES,
60
62
  ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
61
63
  const allLines = visLines(text);
62
64
  const totalLines = allLines.length;
@@ -99,7 +101,7 @@ export async function fmtReadPreview(
99
101
  ? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
100
102
  : fmtRegion([selectedHashes[index]!], [selected[index]!]),
101
103
  );
102
- const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
104
+ const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
103
105
  const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
104
106
  const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
105
107
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
@@ -121,7 +123,7 @@ export async function fmtReadPreview(
121
123
  };
122
124
  }
123
125
 
124
- const truncation = truncateHead(formatted, { maxBytes });
126
+ const truncation = truncateHead(formatted, { maxBytes, maxLines: maxTruncLines });
125
127
 
126
128
  let preview = truncation.content;
127
129
  let nextOffset: number | undefined;
@@ -1,19 +1,4 @@
1
- import { isRec, has } from "./utils";
2
- import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
3
-
4
- export function tryParseContentLines(record: Record<string, unknown>, key: string): void {
5
- const val = record[key];
6
- if (typeof val !== "string") return;
7
- try {
8
- const parsed = JSON.parse(val);
9
- if (Array.isArray(parsed)) {
10
- record[key] = parsed;
11
- return;
12
- }
13
- } catch {
14
- }
15
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
16
- }
1
+ import { isRec } from "./utils";
17
2
 
18
3
  export function normalizeFilePath(record: Record<string, unknown>): void {
19
4
  if (typeof record.path !== "string" && typeof record.file_path === "string") {
@@ -31,9 +16,5 @@ export function normReq(input: unknown): unknown {
31
16
 
32
17
  normalizeFilePath(record);
33
18
 
34
- if (has(record, "content_lines") && typeof record.content_lines === "string") {
35
- tryParseContentLines(record, "content_lines");
36
- }
37
-
38
19
  return record;
39
20
  }
@@ -32,18 +32,14 @@ export function getPreviewInput(
32
32
  return null;
33
33
  }
34
34
 
35
- if (Array.isArray(normalized.changes)) {
36
- return { path: normalized.path, hash_range_inclusive: ["", ""] as [string, string], content_lines: [] };
37
- }
38
-
39
- if (!Array.isArray(normalized.hash_range_inclusive) || !Array.isArray(normalized.content_lines)) {
35
+ if (!Array.isArray(normalized.hash_bounds) || typeof normalized.new_content !== "string") {
40
36
  return null;
41
37
  }
42
38
 
43
39
  const request: ReqParams = {
44
40
  path: normalized.path,
45
- hash_range_inclusive: normalized.hash_range_inclusive as [string, string],
46
- content_lines: normalized.content_lines as string[],
41
+ hash_bounds: normalized.hash_bounds as [string, string],
42
+ new_content: normalized.new_content,
47
43
  };
48
44
  return request;
49
45
  }
package/src/replace.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  type LineEnding,
13
13
  } from "./replace-diff";
14
14
  import { readNormFile } from "./file-reader";
15
- import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
16
- import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
15
+ import { normReq, normalizeFilePath } from "./replace-normalize";
16
+ import { isRec, rejectUnknownFields, abortIf } from "./utils";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import { applyEdit,
19
19
  lineHashes,
@@ -45,12 +45,12 @@ import { loadP, loadGuide } from "./prompts";
45
45
  import { saveUndo } from "./replace-undo";
46
46
  import { loadHashStore, type HashStore } from "./hash-store";
47
47
 
48
- const contentLinesSchema = Type.Array(Type.String(), {
48
+ const newContentSchema = Type.String({
49
49
  description:
50
- "Replacement content, one string per line; entries must not contain line breaks. Use [] to delete the range."
50
+ "Replacement content as a single string with \\n line separators; a trailing newline is the last line's ending, not an extra empty line. Use \"\" to delete the range."
51
51
  });
52
52
 
53
- const hashRangeInclSchema = Type.Array(
53
+ const hashBoundsSchema = Type.Array(
54
54
  Type.String({ description: "A 3-char HASH from read output" }),
55
55
  {
56
56
  description: "Pair of 3-char hashes from read output marking the first and last line of the range to replace (inclusive).",
@@ -62,15 +62,15 @@ const hashRangeInclSchema = Type.Array(
62
62
  export const editToolSchema = Type.Object(
63
63
  {
64
64
  path: Type.String({ description: "Path to edit" }),
65
- hash_range_inclusive: hashRangeInclSchema,
66
- content_lines: contentLinesSchema,
65
+ hash_bounds: hashBoundsSchema,
66
+ new_content: newContentSchema,
67
67
  },
68
68
  { additionalProperties: false },
69
69
  );
70
70
  export type ReqParams = {
71
71
  path: string;
72
- hash_range_inclusive: [string, string];
73
- content_lines: string[];
72
+ hash_bounds: [string, string];
73
+ new_content: string;
74
74
  };
75
75
 
76
76
  export type ReplaceDetails = {
@@ -100,20 +100,7 @@ interface PipelineResult {
100
100
 
101
101
  const PREVIEW_DEBOUNCE_MS = 150;
102
102
 
103
- const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
104
-
105
- const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
106
-
107
- export function assertNoLegacyKeys(request: unknown): void {
108
- if (!isRec(request)) return;
109
- for (const legacyKey of LEGACY_KS) {
110
- if (has(request, legacyKey)) {
111
- throw new Error(
112
- `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
113
- );
114
- }
115
- }
116
- }
103
+ const ROOT_KS = new Set(["path", "new_content", "hash_bounds"]);
117
104
 
118
105
  export function assertReq(
119
106
  request: unknown,
@@ -122,17 +109,15 @@ export function assertReq(
122
109
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
123
110
  }
124
111
 
125
- assertNoLegacyKeys(request);
126
-
127
112
  rejectUnknownFields(request, ROOT_KS, "Edit request");
128
113
 
129
114
  if (typeof request.path !== "string" || request.path.length === 0) {
130
115
  throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
131
116
  }
132
117
 
133
- if (!Array.isArray(request.hash_range_inclusive) || !Array.isArray(request.content_lines)) {
118
+ if (!Array.isArray(request.hash_bounds) || typeof request.new_content !== "string") {
134
119
  throw new Error(
135
- '[E_BAD_SHAPE] Edit request requires both "hash_range_inclusive" and "content_lines" at the top level.',
120
+ '[E_BAD_SHAPE] Edit request requires both "hash_bounds" and "new_content" at the top level.',
136
121
  );
137
122
  }
138
123
  }
@@ -149,8 +134,8 @@ function collectRemovedHashes(
149
134
  originalHashes: string[],
150
135
  ): Set<string> {
151
136
  const removedHashes = new Set<string>();
152
- const startHash = edit.hash_range_inclusive[0].hash;
153
- const endHash = edit.hash_range_inclusive[1].hash;
137
+ const startHash = edit.hash_bounds[0].hash;
138
+ const endHash = edit.hash_bounds[1].hash;
154
139
  const startLine = originalHashes.indexOf(startHash);
155
140
  const endLine = originalHashes.indexOf(endHash);
156
141
  if (startLine >= 0 && endLine >= 0) {
@@ -171,8 +156,8 @@ function countLineChanges(
171
156
  ): { totalAddedLines: number; totalRemovedLines: number } {
172
157
  if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
173
158
  let totalRemovedLines = 0;
174
- const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
175
- const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
159
+ const startLine = originalHashes.indexOf(edit.hash_bounds[0].hash);
160
+ const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
176
161
  if (startLine >= 0 && endLine >= 0) {
177
162
  totalRemovedLines = Math.abs(endLine - startLine) + 1;
178
163
  }
@@ -190,16 +175,16 @@ export async function execPipeline(
190
175
 
191
176
  const path = params.path;
192
177
 
193
- const hashStore = options?.store ?? await loadHashStore();
178
+ const edit = resEdit({
179
+ hash_bounds: params.hash_bounds,
180
+ new_content: params.new_content,
181
+ });
194
182
 
183
+ const hashStore = options?.store ?? await loadHashStore();
195
184
  const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
196
185
  path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore },
197
186
  );
198
187
 
199
- const edit = resEdit({
200
- hash_range_inclusive: params.hash_range_inclusive,
201
- content_lines: params.content_lines,
202
- });
203
188
  const anchorResult = applyEdit(
204
189
  originalNormalized,
205
190
  edit,
@@ -307,13 +292,9 @@ export function buildToolDef(): ToolDef {
307
292
  promptSnippet: E_SNIPPET,
308
293
  promptGuidelines: E_GUIDE,
309
294
  prepareArguments: (args: unknown) => {
310
- assertNoLegacyKeys(args);
311
295
  if (!isRec(args)) return args as any;
312
296
  const record = { ...args };
313
297
  normalizeFilePath(record);
314
- if (has(record, "content_lines") && typeof record.content_lines === "string") {
315
- tryParseContentLines(record, "content_lines");
316
- }
317
298
  return record;
318
299
  },
319
300
  renderShell: "default",