pi-hashline-edit-pro 2.5.2 → 2.6.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
@@ -32,7 +32,7 @@ kQm│}
32
32
  "path": "src/main.ts",
33
33
  "remove_from": "szJ",
34
34
  "remove_to": "szJ",
35
- "replacement_text": " console.log('hi');"
35
+ "replacement_lines": [" console.log('hi');"]
36
36
  }
37
37
  ```
38
38
 
@@ -76,14 +76,14 @@ Edge cases:
76
76
 
77
77
  The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
78
78
 
79
- One edit per call, with `remove_from`, `remove_to`, and `replacement_text` at the top level:
79
+ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at the top level:
80
80
 
81
81
  ```json
82
82
  {
83
83
  "path": "src/main.ts",
84
84
  "remove_from": "szJ",
85
85
  "remove_to": "kQm",
86
- "replacement_text": " console.log('hi');\n}"
86
+ "replacement_lines": [" console.log('hi');", "}"]
87
87
  }
88
88
  ```
89
89
 
@@ -91,12 +91,12 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_text` at th
91
91
  | --- | --- |
92
92
  | `remove_from` | 3-char hash from `read` output marking the FIRST line to remove (inclusive). |
93
93
  | `remove_to` | 3-char hash from `read` output marking the LAST line to remove (inclusive). |
94
- | `replacement_text` | Replacement text as a single string with `\n` line separators; every `\n` separates lines, so a trailing `\n` adds a final empty line mirror the removed lines exactly, blank lines included (a replacement that is only blank lines is written as one `\n` per blank line). Use `""` to delete the range. |
94
+ | `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element each element is exactly one line. |
95
95
 
96
96
  Notes:
97
97
 
98
98
  - The request is checked before any file I/O, so a bad request never touches the file.
99
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_text` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
99
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
100
100
  - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
101
101
  - Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
102
102
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
@@ -157,12 +157,12 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
157
157
 
158
158
  | Code | Meaning |
159
159
  | --- | --- |
160
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_text` must be a string with `\n` line separators). |
160
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
161
161
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char hash. |
162
162
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
163
163
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
164
- | `[E_INVALID_PATCH]` | A `replacement_text` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
165
- | `[E_BARE_HASH_PREFIX]` | A `replacement_text` line starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
164
+ | `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
165
+ | `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
166
166
  | `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
167
167
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
168
168
  | `[E_NOT_FOUND]` | The path does not exist. |
package/index.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  toggleAutoRead,
14
14
  } from "./src/config";
15
15
  import { loadHashStore, pruneMissing } from "./src/hash-store";
16
- import { recordServed, clearServed } from "./src/served";
16
+ import { recordServedSafe, clearServed } from "./src/served";
17
17
  import { readNormFile } from "./src/file-reader";
18
18
  import { loadFileKindAndText } from "./src/file-kind";
19
19
  import { toCwd } from "./src/paths";
@@ -88,12 +88,7 @@ export default function (pi: ExtensionAPI): void {
88
88
  DEFAULT_MAX_BYTES,
89
89
  AUTO_READ_MAX,
90
90
  );
91
- try {
92
- const store = await loadHashStore();
93
- recordServed(store, absolutePath, preview.servedHashes);
94
- } catch (error) {
95
- console.error("Failed to record served state from auto-read:", error);
96
- }
91
+ await recordServedSafe(absolutePath, preview.servedHashes, "auto-read");
97
92
  return {
98
93
  content: [
99
94
  ...(event.content ?? []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.5.2",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,9 +1,9 @@
1
1
  - `replace`: remove_from and remove_to take ONLY the bare 3-char hash — read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `HASH│content` row into these fields.
2
- - `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_text is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_text is deleted from the file.
2
+ - `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_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 replacement_lines is deleted from the file.
3
3
  - `replace`: keep the range as tight as the change — anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
4
4
  - `replace`: to replace a single line, set both remove_from and remove_to to the same hash: remove_from: "<HASH>", remove_to: "<HASH>".
5
5
  - `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
- - `replace`: every `\n` in replacement_text separates lines, so a trailing `\n` adds a final empty line. Mirror the removed lines exactly: a range that ends on a blank line must end replacement_text with `\n` (e.g. `"code\n"`), and a replacement whose last line is not blank must not end with `\n`. To add a blank line after a line, end replacement_text with an explicit empty line after it (e.g. `"X\n"` adds a blank after X). A replacement that is only blank lines is written as one `\n` per blank line.
6
+ - `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element each element is exactly one line.
7
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.
8
8
  - `replace`: `[E_RANGE_STALE]` means a line inside the replaced range changed on disk after it was last shown (or was never shown). Nothing was modified; the error lists the current range as `HASH│content` rows, so retry with those anchors and no `read`.
9
9
  - `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import { configPath } from "./paths";
3
- import { errCode } from "./utils";
3
+ import { errCode, isRec } from "./utils";
4
4
  import { writeAtomic } from "./fs-write";
5
5
 
6
6
  export interface Config {
@@ -12,10 +12,12 @@ const DEFAULT_CONFIG: Config = {
12
12
  };
13
13
 
14
14
  function parseConfig(content: string): Config {
15
- const parsed = JSON.parse(content) as Partial<Config>;
16
- return {
17
- autoRead: parsed.autoRead === true,
18
- };
15
+ const parsed = JSON.parse(content) as unknown;
16
+ const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
17
+ if (typeof autoRead !== "boolean") {
18
+ throw new Error("config.json must be an object with a boolean autoRead field");
19
+ }
20
+ return { autoRead };
19
21
  }
20
22
 
21
23
 
package/src/constants.ts CHANGED
@@ -6,6 +6,6 @@ export const MAX_RANGE_STALE_LINES = 100;
6
6
 
7
7
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
8
8
  export const HASH_STORE_VERSION = 5;
9
- export const NEW_CONTENT_NOT_STRING_MSG =
10
- `[E_BAD_SHAPE] "replacement_text" must be a string with \\n line separators, not an array.`
11
- + ` Do not pass an array of lines — pass the replacement text as one string: "line1\\nline2". Use "" to delete a range.`;
9
+ export const NEW_CONTENT_NOT_ARRAY_MSG =
10
+ `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one element per line, not a single string.`
11
+ + ` Do not pass one string with \\n separators — pass an array of lines: ["line1", "line2"]. Use [] to delete a range.`;
@@ -45,6 +45,18 @@ export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
45
45
  };
46
46
  }
47
47
 
48
+ export async function safeSnapId(
49
+ absolutePath: string,
50
+ context: string,
51
+ ): Promise<string | undefined> {
52
+ try {
53
+ return (await fileSnap(absolutePath)).snapshotId;
54
+ } catch (error) {
55
+ console.error(`Failed to compute snapshot (${context}):`, error);
56
+ return undefined;
57
+ }
58
+ }
59
+
48
60
  export interface ReadNormOptions {
49
61
  signal?: AbortSignal;
50
62
  accessMode?: number;
package/src/hash-store.ts CHANGED
@@ -286,7 +286,11 @@ async function openStore(storePath: string): Promise<HashStore> {
286
286
  const { db, stmts } = opened;
287
287
 
288
288
  if (!existed) {
289
- await migrateLegacy(db);
289
+ try {
290
+ await migrateLegacy(db);
291
+ } catch (error) {
292
+ console.error("Hash store migration failed; continuing without legacy import:", error);
293
+ }
290
294
  }
291
295
  cachedDb = { path: storePath, db, stmts };
292
296
 
@@ -328,20 +332,19 @@ export function shutdownHashStore(): void {
328
332
  }
329
333
 
330
334
  function withStore(fn: () => void): void {
331
- if (cachedDb) {
332
- withBusyRetry(() => {
333
- cachedDb!.db.exec("BEGIN IMMEDIATE");
334
- try {
335
- fn();
336
- cachedDb!.db.exec("COMMIT");
337
- } catch (e) {
338
- try { cachedDb!.db.exec("ROLLBACK"); } catch {}
339
- throw e;
340
- }
341
- });
342
- } else {
343
- fn();
335
+ if (!cachedDb) {
336
+ throw new Error("Hash store is not open; transactional update aborted");
344
337
  }
338
+ withBusyRetry(() => {
339
+ cachedDb!.db.exec("BEGIN IMMEDIATE");
340
+ try {
341
+ fn();
342
+ cachedDb!.db.exec("COMMIT");
343
+ } catch (e) {
344
+ try { cachedDb!.db.exec("ROLLBACK"); } catch {}
345
+ throw e;
346
+ }
347
+ });
345
348
  }
346
349
 
347
350
  async function migrateLegacy(db: DatabaseSync): Promise<void> {
@@ -388,17 +391,19 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
388
391
  ]);
389
392
  }
390
393
  if (rows.length > 0) {
391
- db.exec("BEGIN IMMEDIATE");
392
- try {
393
- const stmt = db.prepare(
394
- "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
395
- );
396
- for (const row of rows) stmt.run(...row);
397
- db.exec("COMMIT");
398
- } catch (e) {
399
- db.exec("ROLLBACK");
400
- throw e;
401
- }
394
+ withBusyRetry(() => {
395
+ db.exec("BEGIN IMMEDIATE");
396
+ try {
397
+ const stmt = db.prepare(
398
+ "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
399
+ );
400
+ for (const row of rows) stmt.run(...row);
401
+ db.exec("COMMIT");
402
+ } catch (e) {
403
+ try { db.exec("ROLLBACK"); } catch {}
404
+ throw e;
405
+ }
406
+ });
402
407
  }
403
408
 
404
409
  try {
@@ -6,9 +6,6 @@ export {
6
6
  HASH_SPACE,
7
7
  HASH_PROBE_STRIDE,
8
8
  MAX_HASH_LINES,
9
- HL_PREFIX_PLUS_RE,
10
- HL_PREFIX_MINUS_RE,
11
- HL_BARE_PREFIX_RE,
12
9
  lineHashes,
13
10
  _lineHashesPure,
14
11
  initHasher,
@@ -22,7 +19,6 @@ export {
22
19
  } from "./parse";
23
20
 
24
21
  export {
25
- type RAnchor,
26
22
  type HEdit,
27
23
  type RHEdit,
28
24
  type HTEdit,
@@ -34,7 +30,6 @@ export {
34
30
  stripBarePrefixes,
35
31
  stripDiffPrefixes,
36
32
  swapReversedRanges,
37
- fmtMismatch,
38
33
  findNewEdge,
39
34
  assertRangeServed,
40
35
  RangeStaleError,
@@ -2,7 +2,7 @@ import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
4
  } from "./hash";
5
- import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
5
+ import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
6
6
 
7
7
  export type Anchor = { hash: string };
8
8
 
@@ -39,12 +39,21 @@ function parseRef(ref: string): Anchor {
39
39
 
40
40
  export const parseHashRef = parseRef;
41
41
 
42
- export function parseText(edit: string): string[] {
43
- if (typeof edit !== "string") {
44
- throw new Error(NEW_CONTENT_NOT_STRING_MSG);
42
+ export function parseText(edit: string[], warnings?: string[]): string[] {
43
+ if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
44
+ throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
45
45
  }
46
- const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
47
- if (normalized === "") return [];
48
- if (/^\n+$/.test(normalized)) return new Array(normalized.length).fill("");
49
- return normalized.split("\n");
46
+ const out: string[] = [];
47
+ let split = false;
48
+ for (const line of edit) {
49
+ const normalized = line.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
50
+ if (normalized !== line) split = true;
51
+ out.push(...normalized.split("\n"));
52
+ }
53
+ if (split) {
54
+ warnings?.push(
55
+ "[E_BAD_SHAPE] Autocorrected: split replacement_lines element(s) containing embedded newlines into separate lines.",
56
+ );
57
+ }
58
+ return out;
50
59
  }
@@ -1,7 +1,7 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
2
  import { HASH_CLASS, HASH_SEP, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
- import { NEW_CONTENT_NOT_STRING_MSG, MAX_RANGE_STALE_LINES } from "../constants";
4
+ import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
@@ -39,7 +39,7 @@ export interface NEdit {
39
39
  }
40
40
 
41
41
  export type HTEdit = {
42
- replacement_text: string;
42
+ replacement_lines: string[];
43
43
  remove_from: string;
44
44
  remove_to: string;
45
45
  };
@@ -133,19 +133,11 @@ export function fmtMismatchWithHashes(
133
133
  return { text: out.join("\n"), hashes };
134
134
  }
135
135
 
136
- export function fmtMismatch(
137
- mismatches: HMismatch[],
138
- fileLines: string[],
139
- fileHashes: string[],
140
- filePath?: string,
141
- ): string {
142
- return fmtMismatchWithHashes(mismatches, fileLines, fileHashes, filePath).text;
143
- }
144
136
 
145
- const ITEM_KS = new Set(["replacement_text", "remove_from", "remove_to"]);
137
+ const ITEM_KS = new Set(["replacement_lines", "remove_from", "remove_to"]);
146
138
 
147
139
  function assertItem(edit: Record<string, unknown>): void {
148
- rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_text, remove_from, remove_to }.");
140
+ rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
149
141
 
150
142
  if ("remove_from" in edit && typeof edit.remove_from !== "string") {
151
143
  throw new Error(
@@ -157,11 +149,11 @@ function assertItem(edit: Record<string, unknown>): void {
157
149
  `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char hash).`,
158
150
  );
159
151
  }
160
- if (!("replacement_text" in edit)) {
161
- throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_text" field. Provide the replacement text (use "" to delete).`);
152
+ if (!("replacement_lines" in edit)) {
153
+ throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" field. Provide the replacement lines as an array of strings (use [] to delete).`);
162
154
  }
163
- if (typeof edit.replacement_text !== "string") {
164
- throw new Error(NEW_CONTENT_NOT_STRING_MSG);
155
+ if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
156
+ throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
165
157
  }
166
158
  if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
167
159
  throw new Error(
@@ -175,7 +167,7 @@ const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
175
167
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
176
168
  assertItem(edit as Record<string, unknown>);
177
169
 
178
- const replaceLines = parseText(edit.replacement_text);
170
+ const replaceLines = parseText(edit.replacement_lines, warnings);
179
171
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
180
172
  const trimmed = ref.trim();
181
173
  const match = trimmed.match(ANCHOR_ROW_RE);
@@ -225,7 +217,7 @@ export function stripBarePrefixes(
225
217
  });
226
218
  if (stripped.length === 0) return edit;
227
219
  const locations = stripped
228
- .map((s) => `replacement_text line ${s.lineIndex + 1}`)
220
+ .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
229
221
  .join(", ");
230
222
  const matchedCount = stripped.filter((s) => s.matched).length;
231
223
  const evidence =
@@ -261,7 +253,7 @@ export function stripDiffPrefixes(
261
253
  return line;
262
254
  });
263
255
  if (stripped.length === 0) return edit;
264
- const locations = stripped.map((i) => `replacement_text line ${i + 1}`).join(", ");
256
+ const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
265
257
  warnings.push(
266
258
  `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
267
259
  );
package/src/read.ts CHANGED
@@ -9,13 +9,11 @@ import {
9
9
  import { Type } from "typebox";
10
10
  import { MAX_READ_LINE_BYTES } from "./constants";
11
11
  import { loadFileKindAndText } from "./file-kind";
12
- import { readNormFile } from "./file-reader";
12
+ import { readNormFile, safeSnapId } from "./file-reader";
13
13
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
14
14
  import { toCwd } from "./paths";
15
- import { abortIf, isRec, normalizeFilePath, visLines } from "./utils";
16
- import { fileSnap } from "./file-reader";
17
- import { loadHashStore } from "./hash-store";
18
- import { recordServed } from "./served";
15
+ import { abortIf, makePrepareArguments, visLines } from "./utils";
16
+ import { recordServedSafe } from "./served";
19
17
  import { loadP, loadGuide } from "./prompts";
20
18
  import { valAccess } from "./validation";
21
19
 
@@ -167,12 +165,7 @@ export function regRead(pi: ExtensionAPI): void {
167
165
  description: R_DESC,
168
166
  promptSnippet: R_SNIPPET,
169
167
  promptGuidelines: readGuide(),
170
- prepareArguments: (args: unknown) => {
171
- if (!isRec(args)) return args as any;
172
- const record = { ...args };
173
- normalizeFilePath(record);
174
- return record;
175
- },
168
+ prepareArguments: makePrepareArguments(),
176
169
  parameters: Type.Object({
177
170
  path: Type.String({
178
171
  description: "Path to the file to read (relative or absolute)",
@@ -223,18 +216,8 @@ export function regRead(pi: ExtensionAPI): void {
223
216
  fileHashes,
224
217
  resolvedPath,
225
218
  );
226
- try {
227
- const store = await loadHashStore();
228
- recordServed(store, resolvedPath, preview.servedHashes);
229
- } catch (error) {
230
- console.error("Failed to record served state from read:", error);
231
- }
232
- let snapshotId: string | undefined;
233
- try {
234
- snapshotId = (await fileSnap(absolutePath)).snapshotId;
235
- } catch (error) {
236
- console.error("Failed to compute snapshot for read:", error);
237
- }
219
+ await recordServedSafe(resolvedPath, preview.servedHashes, "read");
220
+ const snapshotId = await safeSnapId(absolutePath, "read");
238
221
  const previewText =
239
222
  hadUtf8DecodeErrors
240
223
  ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
@@ -35,7 +35,8 @@ export function getPreviewInput(
35
35
  if (
36
36
  typeof normalized.remove_from !== "string" ||
37
37
  typeof normalized.remove_to !== "string" ||
38
- typeof normalized.replacement_text !== "string"
38
+ !Array.isArray(normalized.replacement_lines) ||
39
+ normalized.replacement_lines.some((line) => typeof line !== "string")
39
40
  ) {
40
41
  return null;
41
42
  }
@@ -44,7 +45,7 @@ export function getPreviewInput(
44
45
  path: normalized.path,
45
46
  remove_from: normalized.remove_from,
46
47
  remove_to: normalized.remove_to,
47
- replacement_text: normalized.replacement_text,
48
+ replacement_lines: normalized.replacement_lines,
48
49
  };
49
50
  return request;
50
51
  }
@@ -101,7 +101,7 @@ export function buildNoop(input: NoopInput): TResult {
101
101
  ? `Replacement for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
102
102
  : "The edit produced identical content.";
103
103
 
104
- const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
104
+ const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
105
105
 
106
106
  const metrics = buildMetrics({
107
107
  classification: "noop",
@@ -8,7 +8,7 @@ import { contentChecksum } from "./hashline/hasher";
8
8
  import { resolveTarget, writeAtomic } from "./fs-write";
9
9
  import { toCwd } from "./paths";
10
10
  import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
11
- import { cntDiff, splitLines, errCode, isRec, normalizeFilePath } from "./utils";
11
+ import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
14
14
  import { changedRange, lineHashes } from "./hashline";
@@ -92,12 +92,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
92
92
  description: loadP("../prompts/undo-last-replace.md"),
93
93
  promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
94
94
  promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
95
- prepareArguments: (args: unknown) => {
96
- if (!isRec(args)) return args as any;
97
- const record = { ...args };
98
- normalizeFilePath(record);
99
- return record;
100
- },
95
+ prepareArguments: makePrepareArguments(),
101
96
  parameters: Type.Object({
102
97
  path: Type.String({
103
98
  description: "Path to the file to undo",
package/src/replace.ts CHANGED
@@ -11,9 +11,9 @@ import {
11
11
  restoreEndings,
12
12
  type LineEnding,
13
13
  } from "./replace-diff";
14
- import { readNormFile } from "./file-reader";
14
+ import { readNormFile, safeSnapId } from "./file-reader";
15
15
  import { normReq } from "./replace-normalize";
16
- import { isRec, rejectUnknownFields, abortIf, normalizeFilePath } from "./utils";
16
+ import { isRec, rejectUnknownFields, abortIf, makePrepareArguments } from "./utils";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import { applyEdit,
19
19
  lineHashes,
@@ -26,7 +26,6 @@ import { applyEdit,
26
26
  type NEdit,
27
27
  } from "./hashline";
28
28
  import { toCwd } from "./paths";
29
- import { fileSnap } from "./file-reader";
30
29
  import {
31
30
  buildChanged,
32
31
  buildNoop,
@@ -47,12 +46,18 @@ import {
47
46
  import { loadP, loadGuide } from "./prompts";
48
47
  import { saveUndo } from "./replace-undo";
49
48
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
50
- import { getServed, recordServed, recordServedDiff } from "./served";
49
+ import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
51
50
 
52
- const replacementTextSchema = Type.String({
53
- description:
54
- "Replacement text as a single string with \\n line separators; every \\n separates lines, so a trailing \\n adds a final empty line. Mirror the removed lines exactly, blank lines included. A replacement that is only blank lines is written as one \\n per blank line. Use \"\" to delete the range."
55
- });
51
+ const replacementLinesSchema = Type.Array(
52
+ Type.String({
53
+ description:
54
+ "One replacement line. Each element is exactly one line; do not embed \\n inside an element — use separate elements.",
55
+ }),
56
+ {
57
+ description:
58
+ "Replacement lines as an array of strings, one element per line. Use [] to delete the range."
59
+ }
60
+ );
56
61
 
57
62
  const removeFromSchema = Type.String({
58
63
  description: "Bare 3-char HASH only (e.g. \"aB3\") — copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
@@ -67,7 +72,7 @@ export const editToolSchema = Type.Object(
67
72
  path: Type.Optional(Type.String({ description: "Path to edit. Required — always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
68
73
  remove_from: removeFromSchema,
69
74
  remove_to: removeToSchema,
70
- replacement_text: replacementTextSchema,
75
+ replacement_lines: replacementLinesSchema,
71
76
  },
72
77
  { additionalProperties: false },
73
78
  );
@@ -75,7 +80,7 @@ export type ReqParams = {
75
80
  path: string;
76
81
  remove_from: string;
77
82
  remove_to: string;
78
- replacement_text: string;
83
+ replacement_lines: string[];
79
84
  };
80
85
 
81
86
  export type ReplaceDetails = {
@@ -105,7 +110,7 @@ interface PipelineResult {
105
110
 
106
111
  const PREVIEW_DEBOUNCE_MS = 150;
107
112
 
108
- const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_text"]);
113
+ const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
109
114
 
110
115
  export function assertReq(
111
116
  request: unknown,
@@ -123,10 +128,11 @@ export function assertReq(
123
128
  if (
124
129
  typeof request.remove_from !== "string" ||
125
130
  typeof request.remove_to !== "string" ||
126
- typeof request.replacement_text !== "string"
131
+ !Array.isArray(request.replacement_lines) ||
132
+ request.replacement_lines.some((line) => typeof line !== "string")
127
133
  ) {
128
134
  throw new Error(
129
- '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_text" at the top level.',
135
+ '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" at the top level. replacement_lines must be an array of strings, one element per line (use [] to delete).',
130
136
  );
131
137
  }
132
138
  }
@@ -225,7 +231,7 @@ export async function execPipeline(
225
231
  {
226
232
  remove_from: params.remove_from,
227
233
  remove_to: params.remove_to,
228
- replacement_text: params.replacement_text,
234
+ replacement_lines: params.replacement_lines,
229
235
  },
230
236
  editWarnings,
231
237
  );
@@ -249,17 +255,9 @@ export async function execPipeline(
249
255
  } catch (error) {
250
256
  if (options?.noPersist !== true) {
251
257
  if (error instanceof RangeStaleError) {
252
- try {
253
- recordServed(hashStore, absolutePath, error.rangeHashes);
254
- } catch (recordError) {
255
- console.error("Failed to record served state from range-stale feedback:", recordError);
256
- }
258
+ await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback");
257
259
  } else if (error instanceof AnchorMismatchError) {
258
- try {
259
- recordServed(hashStore, absolutePath, error.feedbackHashes);
260
- } catch (recordError) {
261
- console.error("Failed to record served state from anchor-mismatch feedback:", recordError);
262
- }
260
+ await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback");
263
261
  }
264
262
  }
265
263
  throw error;
@@ -361,12 +359,7 @@ export function buildToolDef(): ToolDef {
361
359
  parameters,
362
360
  promptSnippet: E_SNIPPET,
363
361
  promptGuidelines: E_GUIDE,
364
- prepareArguments: (args: unknown) => {
365
- if (!isRec(args)) return args as any;
366
- const record = { ...args };
367
- normalizeFilePath(record);
368
- return record;
369
- },
362
+ prepareArguments: makePrepareArguments(),
370
363
  renderShell: "default",
371
364
  renderCall(args, theme, context) {
372
365
  const previewInput = getPreviewInput(args);
@@ -512,12 +505,7 @@ export function buildToolDef(): ToolDef {
512
505
 
513
506
  const editsAttempted = 1;
514
507
  if (originalNormalized === result) {
515
- let noopSnapshotId: string | undefined;
516
- try {
517
- noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
518
- } catch (error) {
519
- console.error("Failed to compute snapshot for noop edit:", error);
520
- }
508
+ const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
521
509
  return buildNoop({
522
510
  path,
523
511
  noopEdit,
@@ -561,12 +549,7 @@ export function buildToolDef(): ToolDef {
561
549
  await undo.restore();
562
550
  throw error;
563
551
  }
564
- let updatedSnapshotId: string | undefined;
565
- try {
566
- updatedSnapshotId = (await fileSnap(absolutePath)).snapshotId;
567
- } catch (error) {
568
- console.error("Failed to compute post-edit snapshot:", error);
569
- }
552
+ const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
570
553
 
571
554
  const editMeta: RMeta = {
572
555
  editsAttempted,
@@ -589,12 +572,7 @@ export function buildToolDef(): ToolDef {
589
572
  };
590
573
  const changed = buildChanged(successInput);
591
574
  if (changed.details.diff) {
592
- try {
593
- const store = await loadHashStore();
594
- recordServedDiff(store, mutationTargetPath, changed.details.diff);
595
- } catch (error) {
596
- console.error("Failed to record served state from post-edit diff:", error);
597
- }
575
+ await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff");
598
576
  }
599
577
  return changed;
600
578
  });
package/src/served.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { HashStore } from "./hash-store";
2
- import { parseHashList } from "./hash-store";
1
+ import { loadHashStore, parseHashList, type HashStore } from "./hash-store";
3
2
  import { HASH_CLASS } from "./hashline/alphabet";
4
3
 
5
4
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
@@ -42,3 +41,26 @@ export function recordServedDiff(store: HashStore, path: string, diff: string):
42
41
  export function clearServed(store: HashStore, path: string): void {
43
42
  store.stmts.servedDelete(path);
44
43
  }
44
+
45
+ export async function recordServedSafe(
46
+ path: string,
47
+ hashes: string[],
48
+ context: string,
49
+ ): Promise<void> {
50
+ if (hashes.length === 0) return;
51
+ try {
52
+ const store = await loadHashStore();
53
+ recordServed(store, path, hashes);
54
+ } catch (error) {
55
+ console.error(`Failed to record served state (${context}):`, error);
56
+ }
57
+ }
58
+
59
+ export async function recordServedDiffSafe(
60
+ path: string,
61
+ diff: string,
62
+ context: string,
63
+ ): Promise<void> {
64
+ if (!diff) return;
65
+ await recordServedSafe(path, servedHashesFromDiff(diff), context);
66
+ }
package/src/utils.ts CHANGED
@@ -9,6 +9,15 @@ export function normalizeFilePath(record: Record<string, unknown>): void {
9
9
  }
10
10
  }
11
11
 
12
+ export function makePrepareArguments(): (args: unknown) => any {
13
+ return (args) => {
14
+ if (!isRec(args)) return args;
15
+ const record = { ...args };
16
+ normalizeFilePath(record);
17
+ return record;
18
+ };
19
+ }
20
+
12
21
  export function splitLines(text: string): string[] {
13
22
  if (text.length === 0) return [""];
14
23
  const lines = text.split("\n");