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 +30 -14
- package/package.json +1 -1
- package/src/anchor-registry.ts +22 -5
- package/src/auto-read-all.ts +1 -1
- package/src/batch.ts +127 -41
- package/src/commit.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/copy-move.ts +57 -68
- package/src/edit-common.ts +27 -15
- package/src/grep.ts +50 -118
- package/src/hash-store.ts +13 -12
- package/src/hashline/resolve.ts +13 -2
- package/src/insert.ts +38 -32
- package/src/line-endings.ts +10 -1
- package/src/outline/index.ts +1 -0
- package/src/outline/render.ts +6 -2
- package/src/payload-contract.ts +2 -2
- package/src/read.ts +17 -12
- package/src/replace-match.ts +39 -38
- package/src/replace-undo.ts +1 -1
- package/src/replace.ts +27 -16
- package/tool-prompts/copy.md +1 -1
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert-guidelines.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/read-guidelines.md +2 -2
- package/tool-prompts/read.md +2 -2
- package/tool-prompts/transfer-guidelines.md +1 -1
- package/tool-prompts/undo-last-change-guidelines.md +1 -1
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=
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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]` |
|
|
485
|
-
| `[
|
|
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.
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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)
|
|
241
|
-
if (
|
|
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
|
}
|
package/src/auto-read-all.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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] "${
|
|
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(
|
|
719
|
+
const effectiveHedit = preserveDeletionSeparators(memberPlan.hedit, base.baseLines, base.hashes);
|
|
638
720
|
let planned: PlannedEdit;
|
|
639
|
-
|
|
640
|
-
planned =
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 =
|
|
661
|
-
const separators =
|
|
746
|
+
const foldedLines = memberPlan.foldedLines;
|
|
747
|
+
const separators = memberPlan.contentSeparators ?? memberPlan.hedit.content_separators;
|
|
662
748
|
const carryIndex =
|
|
663
|
-
|
|
664
|
-
?
|
|
665
|
-
:
|
|
666
|
-
?
|
|
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
|
|
673
|
-
...(
|
|
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: [...
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
|
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.`;
|