pi-hashline-edit-pro 6.2.0 → 6.3.1

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
@@ -167,7 +167,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
167
167
  | `offset` | Line number (1-indexed) or a served anchor to start reading from. |
168
168
  | `limit` | Maximum number of lines to return from `offset`. |
169
169
 
170
- Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
170
+ Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=Emno to continue.]`.
171
171
 
172
172
  A line whose `anchor│content` row exceeds 50KB is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50.0KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced through it.
173
173
 
@@ -188,7 +188,7 @@ Edge cases:
188
188
  | --- | --- |
189
189
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
190
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
191
- | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
191
+ | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
192
192
 
193
193
  `replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
194
194
 
@@ -225,7 +225,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
225
225
 
226
226
  `old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
227
227
 
228
- In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` aborts the whole batch unwritten. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
228
+ In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
229
229
 
230
230
  ### insert
231
231
 
@@ -235,7 +235,7 @@ In a same-message batch it joins the other calls on its file: the batch validate
235
235
  | --- | --- |
236
236
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
237
237
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
238
- | `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as `"\n"`), and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
238
+ | `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as a lone newline). A trailing line break sets the newline of the last line written instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
239
239
 
240
240
  Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. An empty `text` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
241
241
 
@@ -289,7 +289,7 @@ Every matching line, and each requested context line, is returned as `lineNumber
289
289
 
290
290
  Directory searches respect `.gitignore` (including parent directories); `.git` is always skipped, and hidden files are searched. `node_modules`, `.tmp`, and `coverage` are skipped only when a `.gitignore` lists them. Binary, image, and oversized files are skipped silently.
291
291
 
292
- Regexes with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned. Use `literal: true` when regex behavior is unnecessary.
292
+ Patterns are evaluated by ripgrep's linear-time regex engine, so shapes that stall a backtracking engine (nested quantifiers, several `.*` terms, huge repetitions) are safe. Pattern syntax is ripgrep's Rust regex syntax; backreferences and look-around are rejected with `[E_GREP_FAILED]`. Use `literal: true` when regex behavior is unnecessary.
293
293
 
294
294
  Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whichever comes first, with a note naming the caps that cut the results (the exact one is in `details.truncation`). A matched line whose `anchor│content` row exceeds 500 bytes is shown as a fragment around the match, with `...` marking the truncated sides; a context row over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable, and `replace` always replaces the whole line.
295
295
 
@@ -314,13 +314,13 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
314
314
  - A call outside a batch commits before its result returns.
315
315
  - A cross-file `move` whose source file also has batched edits in the same message is not grouped: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
316
316
  - A batched cross-file `move` displays only the destination diff: the arrival is covered by the batch's undo, and the source removal is committed with the same batch but is not shown. Read the source file for fresh anchors; it keeps its own undo entry, and `details.patch` still contains the source patch.
317
- - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N (queued)`, and the batch's last call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-batch state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
318
- - If a batch aborts, nothing is written: the failing call's error ends with `Aborts batch N.` and reports that the whole batch was discarded, and an earlier member's row renders the abort message instead of the queued placeholder. Nothing commits until the last call succeeds.
317
+ - A batch validates every call against the pre-batch state and commits once, during the batch's last applied call: earlier applied calls reply `In batch N (queued)`, and the batch's last applied call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-batch state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
318
+ - Before committing, the batch validates every call against the pre-batch state. A call whose own request is invalid (`[E_BAD_SHAPE]`, `[E_BAD_REF]`, `[E_STALE_ANCHOR]`, `[E_RANGE_STALE]`, `[E_SUBSTRING_NOT_FOUND]`, or a strict-input rejection) is left out: it fails on its own call without an `Aborts batch N.` suffix, and the remaining calls still commit as one batch with one undo. A failure of the file, the commit, or a cross-file member aborts the whole batch instead: nothing is written, the failing call's error ends with `Aborts batch N.`, and an earlier member's row renders the abort message instead of the queued placeholder.
319
319
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
320
320
 
321
- Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. A `copy`'s `insert_after` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
321
+ Batched calls must target disjoint ranges; overlapping ranges abort the whole batch unwritten. A `copy`'s `insert_after` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails a batch-level step aborts its batch-mates with `[E_OP_ABORTED]`; a call left out of the batch does not.
322
322
 
323
- A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch and abort it instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file has batched edits in the message is also left out of the batch and commits on its own. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
323
+ A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file has batched edits in the message is also left out of the batch and commits on its own. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
324
324
 
325
325
  The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
326
326
 
@@ -328,7 +328,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
328
328
 
329
329
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call. A model matched by `disableOnModels` skips the block, and the post-edit diff substitution below is skipped with it.
330
330
 
331
- After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
331
+ After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last applied call shows the combined diff, headed by a `batch N:` line; earlier applied calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
332
332
 
333
333
  An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
334
334
 
@@ -340,7 +340,7 @@ Auto-read all is off by default and has three modes, selected in `/hashline-conf
340
340
 
341
341
  Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive; `resources/views/vendor` is kept so Laravel view overrides stay attached), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
342
342
 
343
- Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget.
343
+ Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode, multi-line symbols appear as labels with `[limit N]` and single-line symbols appear as their content.
344
344
 
345
345
  A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget is not counted as complete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
346
346
 
@@ -481,9 +481,8 @@ Full reference:
481
481
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber │ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
482
482
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
483
483
  | `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
484
- | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
485
- | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
486
- | `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern valid in JavaScript but unsupported by ripgrep's regex engine); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
484
+ | `[E_OP_ABORTED]` | A batch-level failure aborted the edit before writing (the base read failed, a cross-file member could not be prepared, the file changed or was deleted during the batch, or the composed result exceeded a limit). Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
485
+ | `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
487
486
  | `[E_GREP_TIMEOUT]` | `anchor_grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
488
487
 
489
488
  ## Troubleshooting
@@ -518,8 +517,25 @@ Config, SQLite (including WAL/SHM and undo history), registry sidecars, and the
518
517
 
519
518
  Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` without deleting records or logging each inaccessible path. Unexpected errors remain visible; tool file-access failures and SQLite errors are not silenced.
520
519
 
520
+ ### Environment variables
521
+
522
+ | Variable | Effect |
523
+ | --- | --- |
524
+ | `PI_HASHLINE_DIR` | Absolute path overriding this extension's state directory on all platforms; an unset or empty value keeps the XDG/home defaults and a relative value is rejected with `[E_CONFIG]`. |
525
+ | `PI_HASHLINE_DEBUG` | Set to `1` or `true` to show the "Hashline Edit mode active" notification at session start. |
526
+
521
527
  ## How anchors work
522
528
 
529
+ ### Request flow
530
+
531
+ ```text
532
+ read / anchor_grep / auto-read-all ──► anchor registry (ownership + served records)
533
+ │
534
+ edit call ──► resolve anchors ──► apply ──► commit ──► post-edit diff with fresh anchors
535
+ │
536
+ hash store (SQLite snapshots + undo)
537
+ ```
538
+
523
539
  ### Allocation
524
540
 
525
541
  Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.2.0",
3
+ "version": "6.3.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -7,7 +7,7 @@ import { sessionClaimsDir } from "./paths";
7
7
  import { contentChecksum } from "./hashline/hasher";
8
8
  import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
9
9
  import { HASH_PROBE_STRIDE } from "./hashline/hash";
10
- import { errCode, splitLines } from "./utils";
10
+ import { errCode, isRec, splitLines } from "./utils";
11
11
  import * as Diff from "diff";
12
12
  import { lineChecksum } from "./hashline";
13
13
  import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
@@ -197,6 +197,7 @@ function seedServedFromOwned(state: SessionState): void {
197
197
  export function foldRegistryEvents(events: RegistryEvent[], seed?: string): SessionState {
198
198
  const state = newSessionState(seed);
199
199
  for (const event of events) {
200
+ if (!isRegistryEvent(event)) continue;
200
201
  if (event.kind === "clear") {
201
202
  state.owned.clear();
202
203
  state.lastUsedAt.clear();
@@ -232,15 +233,31 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
232
233
  return state;
233
234
  }
234
235
 
236
+ function isStringArray(value: unknown): value is string[] {
237
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
238
+ }
239
+
240
+ function isRegistryEvent(value: unknown): value is RegistryEvent {
241
+ if (!isRec(value) || typeof value.kind !== "string") return false;
242
+ if (value.kind === "session") return typeof value.sessionFile === "string";
243
+ if (value.kind === "clear") return true;
244
+ if (value.kind === "allocate") {
245
+ return typeof value.path === "string"
246
+ && Array.isArray(value.rows)
247
+ && value.rows.every((row) => Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && typeof row[1] === "string");
248
+ }
249
+ if (value.kind === "free") return typeof value.path === "string" && (value.anchors === undefined || isStringArray(value.anchors));
250
+ if (value.kind === "minted") return isStringArray(value.anchors);
251
+ return false;
252
+ }
253
+
235
254
  export function parseRegistryLog(raw: string): RegistryEvent[] {
236
255
  const events: RegistryEvent[] = [];
237
256
  for (const line of raw.split("\n")) {
238
257
  if (!line.trim()) continue;
239
258
  try {
240
- const parsed = JSON.parse(line) as RegistryEvent;
241
- if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session" || parsed.kind === "minted")) {
242
- events.push(parsed);
243
- }
259
+ const parsed: unknown = JSON.parse(line);
260
+ if (isRegistryEvent(parsed)) events.push(parsed);
244
261
  } catch {
245
262
  continue;
246
263
  }
@@ -30,7 +30,7 @@ const HEADER =
30
30
  "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
31
 
32
32
  const OUTLINE_HEADER =
33
- "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│symbol` rows with live anchors.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor and the row's `limit`; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
33
+ "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
34
34
 
35
35
  const IMAGE_EXTENSIONS = new Set([
36
36
  ".avif",
package/src/batch.ts CHANGED
@@ -41,6 +41,8 @@ export interface PlannedMember {
41
41
  order: number;
42
42
  size: number;
43
43
  last: boolean;
44
+ plan?: BatchMemberPlan;
45
+ demoted?: Error;
44
46
  }
45
47
 
46
48
  export type BatchKind = "replace" | "insert" | "replace_match" | "copy" | "move";
@@ -64,6 +66,36 @@ export interface BatchSourceMove {
64
66
  pipe: PipelineResult;
65
67
  }
66
68
 
69
+ export interface BatchMemberPlan {
70
+ hedit: HEdit;
71
+ warnings: string[];
72
+ planned?: PlannedEdit;
73
+ foldedLines: number;
74
+ direction?: "before" | "after";
75
+ stripWarning?: StripWarningLocation;
76
+ contentSeparators?: (LineEnding | undefined)[];
77
+ carryIndex?: number;
78
+ servedOverride?: ReadonlyMap<string, string>;
79
+ sourceMove?: BatchSourceMove;
80
+ }
81
+
82
+ export interface BatchPlannerInput {
83
+ member: PlannedMember;
84
+ base: BatchBase;
85
+ displayPath: string;
86
+ served?: ReadonlyMap<string, string>;
87
+ cwd: string;
88
+ signal?: AbortSignal;
89
+ }
90
+
91
+ export type BatchMemberPlanner = (input: BatchPlannerInput) => BatchMemberPlan | undefined | Promise<BatchMemberPlan | undefined>;
92
+
93
+ const batchPlanners = new Map<BatchKind, BatchMemberPlanner>();
94
+
95
+ export function registerBatchPlanner(kind: BatchKind, planner: BatchMemberPlanner): void {
96
+ batchPlanners.set(kind, planner);
97
+ }
98
+
67
99
  interface SourcePreparation {
68
100
  move: BatchSourceMove;
69
101
  separators?: LineEnding[];
@@ -88,21 +120,12 @@ export interface BatchPiece {
88
120
  }
89
121
 
90
122
  export interface BatchMemberInput {
91
- kind: BatchKind;
92
- direction?: "before" | "after";
93
123
  member: PlannedMember;
94
124
  targetPath: string;
95
125
  mutationTargetPath: string;
96
126
  cwd: string;
97
127
  signal?: AbortSignal;
98
- hedit: HEdit;
99
- extraWarnings: string[];
100
- foldedLines?: number;
101
- stripWarning?: StripWarningLocation;
102
- contentSeparators?: (LineEnding | undefined)[];
103
- carryIndex?: number;
104
- servedOverride?: ReadonlyMap<string, string>;
105
- sourceMove?: BatchSourceMove;
128
+ plan?: BatchMemberPlan;
106
129
  }
107
130
 
108
131
  interface BatchFailure {
@@ -115,7 +138,9 @@ interface BatchState {
115
138
  display: number;
116
139
  target: string;
117
140
  memberIds: string[];
141
+ allMemberIds: string[];
118
142
  stale: boolean;
143
+ preflighted?: boolean;
119
144
  base?: BatchBase;
120
145
  paths?: { absolutePath: string; mutationTargetPath: string; displayPath: string };
121
146
  served?: ReadonlyMap<string, string>;
@@ -335,7 +360,7 @@ function enforceCap(): void {
335
360
  const state = batches.get(oldest);
336
361
  batches.delete(oldest);
337
362
  if (state) {
338
- for (const id of state.memberIds) {
363
+ for (const id of state.allMemberIds) {
339
364
  plan.delete(id);
340
365
  placeholderResults.delete(id);
341
366
  }
@@ -397,6 +422,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
397
422
  display,
398
423
  target: group[0]!.target,
399
424
  memberIds: group.map((item) => item.id),
425
+ allMemberIds: group.map((item) => item.id),
400
426
  stale: false,
401
427
  pieces: [],
402
428
  sources: [],
@@ -565,6 +591,49 @@ function batchAbortedError(runtime: BatchState): Error {
565
591
  discardBatchState(runtime);
566
592
  return new Error(abortedBatchMessage(runtime));
567
593
  }
594
+
595
+ const ATTRIBUTABLE_BATCH_CODES = new Set(["E_BAD_SHAPE", "E_BAD_REF", "E_STALE_ANCHOR", "E_RANGE_STALE", "E_SUBSTRING_NOT_FOUND"]);
596
+
597
+ function isAttributableBatchError(error: unknown): boolean {
598
+ const code = errorCodeOf(error);
599
+ return code !== undefined && ATTRIBUTABLE_BATCH_CODES.has(code);
600
+ }
601
+
602
+ async function preflightBatch(runtime: BatchState, base: BatchBase, cwd: string, signal?: AbortSignal): Promise<void> {
603
+ if (runtime.preflighted) return;
604
+ runtime.preflighted = true;
605
+ if (runtime.failed) return;
606
+ const displayPath = runtime.paths?.displayPath ?? runtime.target;
607
+ for (const id of [...runtime.memberIds]) {
608
+ const member = plan.get(id);
609
+ if (!member || member.demoted) continue;
610
+ const planner = batchPlanners.get(member.kind);
611
+ if (!planner) continue;
612
+ try {
613
+ const memberPlan = await planner({ member, base, displayPath, served: runtime.served, cwd, signal });
614
+ if (memberPlan === undefined) continue;
615
+ await throwIfStrictInput([...memberPlan.warnings, ...(memberPlan.planned?.warnings ?? [])]);
616
+ member.plan = memberPlan;
617
+ } catch (error) {
618
+ if (!isAttributableBatchError(error)) {
619
+ noteBatchFailure(member, error);
620
+ throw error;
621
+ }
622
+ if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
623
+ else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
624
+ member.demoted = error instanceof Error ? error : new Error(String(error));
625
+ runtime.memberIds = runtime.memberIds.filter((candidate) => candidate !== id);
626
+ }
627
+ }
628
+ const survivors = runtime.memberIds
629
+ .map((id) => plan.get(id))
630
+ .filter((member): member is PlannedMember => member !== undefined);
631
+ survivors.forEach((member, index) => {
632
+ member.size = survivors.length;
633
+ member.last = index === survivors.length - 1;
634
+ });
635
+ }
636
+
568
637
  function discardBatchState(runtime: BatchState): void {
569
638
  markBatchMembersAborted(runtime);
570
639
  }
@@ -578,7 +647,10 @@ export async function ensureBatchBase(input: {
578
647
  }): Promise<BatchBase> {
579
648
  const runtime = batches.get(input.member.batchKey);
580
649
  if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
581
- if (runtime.base) return runtime.base;
650
+ if (runtime.base) {
651
+ await preflightBatch(runtime, runtime.base, input.cwd, input.signal);
652
+ return runtime.base;
653
+ }
582
654
  abortIf(input.signal);
583
655
  const file = await readNormFile(input.targetPath, input.cwd, {
584
656
  signal: input.signal,
@@ -606,6 +678,7 @@ export async function ensureBatchBase(input: {
606
678
  mutationTargetPath: input.mutationTargetPath,
607
679
  displayPath: toDisplayPath(input.cwd, file.absolutePath, input.targetPath),
608
680
  };
681
+ await preflightBatch(runtime, base, input.cwd, input.signal);
609
682
  return base;
610
683
  }
611
684
 
@@ -627,29 +700,42 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
627
700
  discardBatchState(runtime);
628
701
  throw error;
629
702
  }
703
+ if (runtime.failed) throw batchAbortedError(runtime);
704
+ if (input.member.demoted) throw input.member.demoted;
705
+ const memberPlan = input.plan ?? input.member.plan;
706
+ if (memberPlan === undefined) {
707
+ const error = new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: no plan for [${input.member.kind}] Call Nr ${input.member.order}. ${BATCH_DISCARDED_NOTE}`);
708
+ noteBatchFailure(input.member, error);
709
+ throw batchAbortedError(runtime);
710
+ }
630
711
  if (input.mutationTargetPath !== input.member.target) {
631
- const error = new Error(`[E_STALE_ANCHOR] "${input.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
712
+ const error = new Error(`[E_STALE_ANCHOR] "${memberPlan.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
632
713
  noteBatchFailure(input.member, error);
633
714
  discardBatchState(runtime);
634
715
  throw error;
635
716
  }
717
+ const kind = input.member.kind;
636
718
  const displayPath = runtime.paths?.displayPath ?? input.targetPath;
637
- const effectiveHedit = preserveDeletionSeparators(input.hedit, base.baseLines, base.hashes);
719
+ const effectiveHedit = preserveDeletionSeparators(memberPlan.hedit, base.baseLines, base.hashes);
638
720
  let planned: PlannedEdit;
639
- try {
640
- planned = planEdit(base.content, effectiveHedit, base.hashes, {
641
- filePath: displayPath,
642
- servedHashes: input.servedOverride ?? runtime.served,
643
- signal: input.signal,
644
- baseFileLines: base.baseLines,
645
- stripWarning: input.stripWarning,
646
- });
647
- } catch (error) {
648
- if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
649
- else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
650
- noteBatchFailure(input.member, error);
651
- discardBatchState(runtime);
652
- throw error;
721
+ if (memberPlan.planned !== undefined) {
722
+ planned = memberPlan.planned;
723
+ } else {
724
+ try {
725
+ planned = planEdit(base.content, effectiveHedit, base.hashes, {
726
+ filePath: displayPath,
727
+ servedHashes: memberPlan.servedOverride ?? runtime.served,
728
+ signal: input.signal,
729
+ baseFileLines: base.baseLines,
730
+ stripWarning: memberPlan.stripWarning,
731
+ });
732
+ } catch (error) {
733
+ if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
734
+ else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
735
+ noteBatchFailure(input.member, error);
736
+ discardBatchState(runtime);
737
+ throw error;
738
+ }
653
739
  }
654
740
  const start = planned.resolved.hash_bounds[0].line;
655
741
  const end = planned.resolved.hash_bounds[1].line;
@@ -657,20 +743,20 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
657
743
  const baseLines = base.baseLines;
658
744
  const originalSlice = baseLines.slice(start - 1, end);
659
745
  const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
660
- const foldedLines = input.foldedLines ?? 0;
661
- const separators = input.contentSeparators ?? input.hedit.content_separators;
746
+ const foldedLines = memberPlan.foldedLines;
747
+ const separators = memberPlan.contentSeparators ?? memberPlan.hedit.content_separators;
662
748
  const carryIndex =
663
- input.carryIndex !== undefined
664
- ? input.carryIndex
665
- : input.kind === "insert" && foldedLines > 0
666
- ? input.direction === "after"
749
+ memberPlan.carryIndex !== undefined
750
+ ? memberPlan.carryIndex
751
+ : kind === "insert" && foldedLines > 0
752
+ ? memberPlan.direction === "after"
667
753
  ? 0
668
754
  : newLines.length - 1
669
755
  : undefined;
670
756
  const piece: BatchPiece = {
671
757
  order: input.member.order,
672
- kind: input.kind,
673
- ...(input.direction !== undefined ? { direction: input.direction } : {}),
758
+ kind,
759
+ ...(memberPlan.direction !== undefined ? { direction: memberPlan.direction } : {}),
674
760
  ...(carryIndex !== undefined ? { carryIndex } : {}),
675
761
  start,
676
762
  end,
@@ -678,7 +764,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
678
764
  toHash: planned.resolved.hash_bounds[1].hash,
679
765
  newLines: [...newLines],
680
766
  ...(separators !== undefined ? { separators } : {}),
681
- warnings: [...input.extraWarnings, ...planned.warnings],
767
+ warnings: [...memberPlan.warnings, ...planned.warnings],
682
768
  noop,
683
769
  foldedLines,
684
770
  };
@@ -686,7 +772,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
686
772
  if (noop) runtime.noops += 1;
687
773
  else runtime.applied += 1;
688
774
  runtime.warnings.push(...piece.warnings);
689
- if (input.sourceMove !== undefined) runtime.sources.push(input.sourceMove);
775
+ if (memberPlan.sourceMove !== undefined) runtime.sources.push(memberPlan.sourceMove);
690
776
  if (!input.member.last) {
691
777
  const placeholder = batchPlaceholder(input.member, piece, base.snapshotId);
692
778
  placeholderResults.set(input.member.id, placeholder);
@@ -817,7 +903,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
817
903
  const spans = composed.length === 0
818
904
  ? [{ start: 0, end: base.hashes.length - 1, replacementCount: 1 }]
819
905
  : pieceMappingSpans(effectivePieces);
820
- const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending);
906
+ const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending, base.baseLines);
821
907
  applySpanEndings(resultSeparators, effectivePieces.map((piece) => ({
822
908
  start: piece.start - 1,
823
909
  end: piece.end - 1,
@@ -883,7 +969,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
883
969
  }
884
970
  const sourceSpan = source.pipe.spans?.[0];
885
971
  const sourceSeparators = sourceSpan
886
- ? separatorsForSpans(source.pipe.originalSeparators, source.pipe.originalHashes.length, [sourceSpan], source.pipe.result, source.pipe.originalEnding)
972
+ ? separatorsForSpans(source.pipe.originalSeparators, source.pipe.originalHashes.length, [sourceSpan], source.pipe.result, source.pipe.originalEnding, splitLines(source.pipe.originalNormalized))
887
973
  : undefined;
888
974
  const sourceBytes = source.pipe.bom + (sourceSeparators !== undefined
889
975
  ? joinSeparators(source.pipe.result, sourceSeparators)
@@ -1102,7 +1188,7 @@ export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
1102
1188
  for (const key of keys) {
1103
1189
  const runtime = batches.get(key);
1104
1190
  if (!runtime) continue;
1105
- for (const id of runtime.memberIds) {
1191
+ for (const id of runtime.allMemberIds) {
1106
1192
  plan.delete(id);
1107
1193
  placeholderResults.delete(id);
1108
1194
  }
package/src/commit.ts CHANGED
@@ -37,7 +37,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
37
37
  const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
38
38
  if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
39
39
  const resultSeparators = span
40
- ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
40
+ ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding, splitLines(pipe.originalNormalized))
41
41
  : undefined;
42
42
  if (resultSeparators !== undefined && span !== undefined) {
43
43
  applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
package/src/constants.ts CHANGED
@@ -14,10 +14,10 @@ export const NEW_CONTENT_NOT_ARRAY_MSG =
14
14
  `[E_BAD_SHAPE] "text" must be an array of strings, one per line (use [] to delete).`;
15
15
 
16
16
  export const NEW_CONTENT_NOT_STRING_MSG =
17
- `[E_BAD_SHAPE] "text" must be a string holding the exact text to write. Use "" to delete the range and "\\n" for one blank line; line breaks inside the string separate lines.`;
17
+ `[E_BAD_SHAPE] "text" must be a string holding the exact text to write. Use "" to delete the range and a lone newline for one blank line; line breaks inside the string separate lines.`;
18
18
 
19
19
  export const TEXT_NOT_STRING_MSG =
20
- `[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use "\\n" for one blank line; line breaks inside the string separate lines.`;
20
+ `[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use a lone newline for one blank line; line breaks inside the string separate lines.`;
21
21
 
22
22
  export const NUL_CONTENT_MSG =
23
23
  `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry.`;