pi-hashline-edit-pro 4.2.11 → 4.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 +19 -11
- package/index.ts +30 -8
- package/package.json +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +0 -1
- package/prompts/insert.md +1 -1
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/src/anchor-registry.ts +21 -31
- package/src/auto-read-all.ts +309 -0
- package/src/batch.ts +3 -3
- package/src/commit.ts +10 -19
- package/src/config-ui.ts +2 -1
- package/src/config.ts +68 -28
- package/src/constants.ts +6 -0
- package/src/edit-common.ts +6 -0
- package/src/fs-write.ts +1 -1
- package/src/grep.ts +12 -18
- package/src/hash-store/cache.ts +0 -18
- package/src/hash-store/retry.ts +0 -4
- package/src/hash-store/validation.ts +0 -31
- package/src/hash-store.ts +22 -32
- package/src/hashline/apply.ts +2 -2
- package/src/hashline/hash.ts +7 -8
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +3 -16
- package/src/hashline/resolve.ts +19 -39
- package/src/payload-contract.ts +4 -12
- package/src/read.ts +3 -3
- package/src/replace-diff.ts +107 -108
- package/src/replace-response.ts +37 -3
- package/src/replace-undo.ts +9 -9
- package/src/replace.ts +11 -5
- package/src/served.ts +7 -3
- package/src/utils.ts +9 -8
- package/src/write-hook.ts +0 -4
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ The extension registers five tools: `read`, `replace`, `insert`, `anchor_grep`,
|
|
|
54
54
|
|
|
55
55
|
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.]`.
|
|
56
56
|
|
|
57
|
-
A line
|
|
57
|
+
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.
|
|
58
58
|
|
|
59
59
|
Edge cases:
|
|
60
60
|
|
|
@@ -89,7 +89,7 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
|
|
|
89
89
|
|
|
90
90
|
The request is checked before any file I/O, so a bad request never touches the file.
|
|
91
91
|
|
|
92
|
-
Common copy-paste slips are fixed automatically
|
|
92
|
+
Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 characters before `│`, for example `ab12│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, a JSON-array wrapper, and embedded newlines are corrected silently. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config`: `on` strips with a warning, `off` applies edits literally, and `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped.
|
|
93
93
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
|
|
94
94
|
|
|
95
95
|
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
|
|
@@ -99,7 +99,7 @@ An edit that produces identical content reports `No changes made` and leaves the
|
|
|
99
99
|
After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
|
|
100
100
|
|
|
101
101
|
Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; 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` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call. A member whose boundary dedup cuts it to a noop contributes no hunk to the combined diff: the batch warning names it instead of pointing at `dedup│` rows. The `dedup│` rows in a batch diff cover its applied members only.
|
|
102
|
-
|
|
102
|
+
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`.
|
|
103
103
|
Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. 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]`. A call whose anchors resolve nowhere never joins a batch: it runs solo 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, join their file's batch and abort it instead of applying partially. 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.
|
|
104
104
|
|
|
105
105
|
### insert
|
|
@@ -136,7 +136,7 @@ Directory searches respect `.gitignore` (including parent directories); `.git` i
|
|
|
136
136
|
|
|
137
137
|
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.
|
|
138
138
|
|
|
139
|
-
Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whichever comes first, with a note naming the
|
|
139
|
+
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.
|
|
140
140
|
|
|
141
141
|
### undo_last_change
|
|
142
142
|
|
|
@@ -156,7 +156,15 @@ Auto-read is enabled by default. After a successful `write`, the extension reads
|
|
|
156
156
|
|
|
157
157
|
After `replace`, `insert`, 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`. 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.
|
|
158
158
|
|
|
159
|
-
Auto-read keeps the same 50KB and 2000-line budget as `read`.
|
|
159
|
+
Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff context live in `/hashline-config` and persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
|
|
160
|
+
|
|
161
|
+
### Auto-read all
|
|
162
|
+
|
|
163
|
+
Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
|
|
164
|
+
|
|
165
|
+
Files are filtered before injection: symlinks, directories, image extensions, binary files (a NUL byte in the first 8KB), files over 200KB, and any file whose name is in the built-in skip list (currently `package-lock.json`, matched by file name anywhere in the tree) are skipped. The attachment stops at 500 files or at a byte budget derived from the model's 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 file whose `read` output is truncated keeps its truncation hint, so the rest can be paged in with `read`.
|
|
166
|
+
|
|
167
|
+
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`).
|
|
160
168
|
|
|
161
169
|
## Tool result details
|
|
162
170
|
|
|
@@ -165,7 +173,7 @@ All five tools return machine-readable metadata in `details` alongside the model
|
|
|
165
173
|
| Tool | `details` |
|
|
166
174
|
| --- | --- |
|
|
167
175
|
| `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
|
|
168
|
-
| `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-
|
|
176
|
+
| `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
169
177
|
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
170
178
|
| `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
|
|
171
179
|
|
|
@@ -173,7 +181,7 @@ All five tools return machine-readable metadata in `details` alongside the model
|
|
|
173
181
|
|
|
174
182
|
| Command | Description |
|
|
175
183
|
| --- | --- |
|
|
176
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
184
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
177
185
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
178
186
|
|
|
179
187
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -181,6 +189,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
181
189
|
```json
|
|
182
190
|
{
|
|
183
191
|
"autoRead": true,
|
|
192
|
+
"autoReadAll": "off",
|
|
184
193
|
"anchorGrepEnabled": true,
|
|
185
194
|
"requirePath": false,
|
|
186
195
|
"strictInput": false,
|
|
@@ -193,7 +202,7 @@ On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (fallin
|
|
|
193
202
|
|
|
194
203
|
## How anchors work
|
|
195
204
|
|
|
196
|
-
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 large stride (roughly the golden ratio of the anchor space) coprime to it, 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
|
|
205
|
+
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 large stride (roughly the golden ratio of the anchor space) coprime to it, 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, or you ran `/clear-anchors`). 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: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
197
206
|
|
|
198
207
|
The table is curated for tokenizers, not for humans. Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
|
|
199
208
|
|
|
@@ -219,13 +228,12 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
219
228
|
| Code | Meaning |
|
|
220
229
|
| --- | --- |
|
|
221
230
|
| `[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), or content contains a NUL byte (`U+0000`), which would make the file binary. |
|
|
222
|
-
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example
|
|
231
|
+
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
|
|
223
232
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
|
|
224
233
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
225
234
|
| `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
|
|
226
235
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
227
236
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
228
|
-
| `[W_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
|
|
229
237
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
230
238
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
231
239
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
@@ -237,7 +245,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
237
245
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
238
246
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
239
247
|
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
|
240
|
-
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with
|
|
248
|
+
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with an `anchor│` that was served for this file (any line position). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
241
249
|
| `[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. |
|
|
242
250
|
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` 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. |
|
|
243
251
|
| `[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. |
|
package/index.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { initHasher } from "./src/hashline";
|
|
3
|
+
import { initHasher, lineChecksum } from "./src/hashline";
|
|
4
4
|
import { regReplace } from "./src/replace";
|
|
5
5
|
import { regInsert } from "./src/insert";
|
|
6
6
|
import { regGrep } from "./src/grep";
|
|
7
7
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
8
8
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
9
|
+
import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
|
|
9
10
|
import type { RMetrics } from "./src/replace-response";
|
|
10
11
|
import type { ReplaceDetails } from "./src/replace";
|
|
11
12
|
import { extractWarnings } from "./src/replace-render";
|
|
12
13
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
14
|
+
import type { AutoReadAllMode } from "./src/config";
|
|
13
15
|
import {
|
|
14
16
|
readConfigWithStatus,
|
|
15
17
|
toggleAutoRead,
|
|
18
|
+
cycleAutoReadAllMode,
|
|
16
19
|
toggleAnchorGrep,
|
|
17
20
|
toggleRequirePath,
|
|
18
21
|
toggleStrictInput,
|
|
@@ -20,8 +23,8 @@ import {
|
|
|
20
23
|
adjustDiffContextLines,
|
|
21
24
|
} from "./src/config";
|
|
22
25
|
import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
|
|
23
|
-
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors,
|
|
24
|
-
import {
|
|
26
|
+
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
27
|
+
import { serveRows } from "./src/served";
|
|
25
28
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
26
29
|
import { currentEditFlags } from "./src/edit-common";
|
|
27
30
|
import { HashlineConfigOverlay } from "./src/config-ui";
|
|
@@ -31,8 +34,7 @@ import { loadFileKindAndText } from "./src/file-kind";
|
|
|
31
34
|
import { resolveInCwd } from "./src/fs-write";
|
|
32
35
|
import { valAccess } from "./src/validation";
|
|
33
36
|
import { splitLines } from "./src/utils";
|
|
34
|
-
import {
|
|
35
|
-
import { contentChecksum } from "./src/hashline/hasher";
|
|
37
|
+
import { AUTO_READ_ALL_CUSTOM_TYPE } from "./src/constants";
|
|
36
38
|
|
|
37
39
|
export default function (pi: ExtensionAPI): void {
|
|
38
40
|
regRead(pi);
|
|
@@ -44,6 +46,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
44
46
|
registerWriteHook(pi);
|
|
45
47
|
|
|
46
48
|
let autoRead = true;
|
|
49
|
+
let autoReadAll: AutoReadAllMode = "off";
|
|
50
|
+
let autoReadAllInjected = false;
|
|
47
51
|
let grepWasActive = false;
|
|
48
52
|
|
|
49
53
|
async function refreshEditTools(): Promise<void> {
|
|
@@ -77,6 +81,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
77
81
|
const { config, corrupted } = await readConfigWithStatus();
|
|
78
82
|
if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
|
|
79
83
|
autoRead = config.autoRead;
|
|
84
|
+
autoReadAll = config.autoReadAll ?? "off";
|
|
85
|
+
const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
|
|
86
|
+
autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
|
|
80
87
|
await refreshEditTools();
|
|
81
88
|
pi.setActiveTools(
|
|
82
89
|
pi.getActiveTools().filter((t) =>
|
|
@@ -98,8 +105,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
98
105
|
}
|
|
99
106
|
});
|
|
100
107
|
|
|
108
|
+
pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
|
|
109
|
+
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
110
|
+
autoReadAllInjected = true;
|
|
111
|
+
try {
|
|
112
|
+
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll);
|
|
113
|
+
if (!injection) return;
|
|
114
|
+
if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
|
|
115
|
+
return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error("Auto-read all failed:", error);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}));
|
|
121
|
+
|
|
101
122
|
pi.registerCommand("hashline-config", {
|
|
102
|
-
description: "Open the hashline settings window (auto-read, diff context, grep, path, strict input, dedup)",
|
|
123
|
+
description: "Open the hashline settings window (auto-read, auto-read all, diff context, grep, path, strict input, dedup)",
|
|
103
124
|
handler: async (_args, ctx) => {
|
|
104
125
|
if (!ctx.hasUI) {
|
|
105
126
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -112,6 +133,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
112
133
|
done,
|
|
113
134
|
onToggle: async (key, delta) => {
|
|
114
135
|
if (key === "autoRead") autoRead = await toggleAutoRead();
|
|
136
|
+
else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
|
|
115
137
|
else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
|
|
116
138
|
else if (key === "anchorGrepEnabled") {
|
|
117
139
|
const enabled = await toggleAnchorGrep();
|
|
@@ -189,8 +211,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
189
211
|
DEFAULT_MAX_LINES,
|
|
190
212
|
);
|
|
191
213
|
const fileLines = splitLines(normalized);
|
|
192
|
-
persistSnapshot(await loadHashStore(), absolutePath, normalized, fileHashes, fileLines.map(
|
|
193
|
-
|
|
214
|
+
persistSnapshot(await loadHashStore(), absolutePath, normalized, fileHashes, fileLines.map(lineChecksum));
|
|
215
|
+
serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
|
|
194
216
|
return {
|
|
195
217
|
content: [
|
|
196
218
|
...(event.content ?? []),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.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/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When the output says truncated, refine `pattern` or raise `limit
|
|
1
|
+
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When the output says truncated, refine `pattern` or raise `limit` (default 100).
|
|
@@ -1,3 +1,2 @@
|
|
|
1
1
|
- `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
|
|
2
|
-
- `insert`: same-file calls in one message join the file's batch: earlier calls reply `In batch N`, the last call shows the combined diff.
|
|
3
2
|
- `insert`: a batch may pair one `before` and one `after` on the same anchor line; the pair composes into a single insertion. Any other same-line pair is an overlap.
|
package/prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert lines after or before one existing line in a text file, addressed by a bare anchor from read output or a diff row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file
|
|
1
|
+
Insert lines after or before one existing line in a text file, addressed by a bare anchor from read output or a diff row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
- `replace`: edit with `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
|
|
2
2
|
- `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
|
|
3
|
-
- `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed
|
|
3
|
+
- `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed; never anchor on `-anchor│` rows, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
|
|
4
4
|
- `replace`: batched calls must target disjoint ranges and all be valid; an overlap or any failure aborts the whole batch with nothing applied.
|
|
5
5
|
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically, shown as `dedup│content` rows in the diff (not editable, never use `dedup` as an anchor).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad diff
|
|
1
|
+
- `undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
Single-level undo: reverts a file's last `replace` or `insert`
|
package/src/anchor-registry.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { contentChecksum } from "./hashline/hasher";
|
|
|
8
8
|
import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
|
|
9
9
|
import { HASH_PROBE_STRIDE } from "./hashline/hash";
|
|
10
10
|
import { errCode, splitLines } from "./utils";
|
|
11
|
-
import { hashSource } from "./hashline";
|
|
12
11
|
import * as Diff from "diff";
|
|
12
|
+
import { lineChecksum } from "./hashline";
|
|
13
13
|
import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
|
|
14
14
|
import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
|
|
15
15
|
|
|
@@ -606,12 +606,14 @@ interface MintedAt {
|
|
|
606
606
|
checksum: string;
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
type ArrayPart = { count: number; added?: boolean; removed?: boolean };
|
|
610
|
+
|
|
609
611
|
function computeParts(
|
|
610
612
|
prevChecksums: string[] | undefined,
|
|
611
613
|
newChecksums: string[],
|
|
612
|
-
):
|
|
614
|
+
): ArrayPart[] {
|
|
613
615
|
if (!prevChecksums) {
|
|
614
|
-
return [{ count: newChecksums.length, added: true
|
|
616
|
+
return [{ count: newChecksums.length, added: true }];
|
|
615
617
|
}
|
|
616
618
|
const min = Math.min(prevChecksums.length, newChecksums.length);
|
|
617
619
|
let prefix = 0;
|
|
@@ -624,37 +626,25 @@ function computeParts(
|
|
|
624
626
|
suffix++;
|
|
625
627
|
}
|
|
626
628
|
if (prefix + suffix >= min && prevChecksums.length === newChecksums.length) {
|
|
627
|
-
return [{ count: newChecksums.length
|
|
629
|
+
return [{ count: newChecksums.length }];
|
|
628
630
|
}
|
|
629
631
|
const prevMid = prevChecksums.slice(prefix, prevChecksums.length - suffix);
|
|
630
632
|
const newMid = newChecksums.slice(prefix, newChecksums.length - suffix);
|
|
631
633
|
if (prevMid.length === 0) {
|
|
632
|
-
return [
|
|
633
|
-
{ count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
|
|
634
|
-
{ count: newMid.length, added: true, removed: false, value: [] } as unknown as Diff.ArrayChange<string>,
|
|
635
|
-
{ count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
|
|
636
|
-
];
|
|
634
|
+
return [{ count: prefix }, { count: newMid.length, added: true }, { count: suffix }];
|
|
637
635
|
}
|
|
638
636
|
if (newMid.length === 0) {
|
|
639
|
-
return [
|
|
640
|
-
{ count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
|
|
641
|
-
{ count: prevMid.length, removed: true, added: false, value: [] } as unknown as Diff.ArrayChange<string>,
|
|
642
|
-
{ count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
|
|
643
|
-
];
|
|
637
|
+
return [{ count: prefix }, { count: prevMid.length, removed: true }, { count: suffix }];
|
|
644
638
|
}
|
|
645
639
|
if (prevMid.length * newMid.length > 4_000_000) {
|
|
646
|
-
return [
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const parts: Diff.ArrayChange<string>[] = [];
|
|
655
|
-
if (prefix > 0) parts.push({ count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>);
|
|
656
|
-
parts.push(...midParts);
|
|
657
|
-
if (suffix > 0) parts.push({ count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>);
|
|
640
|
+
return [{ count: prefix }, { count: prevMid.length, removed: true }, { count: newMid.length, added: true }, { count: suffix }];
|
|
641
|
+
}
|
|
642
|
+
const parts: ArrayPart[] = [];
|
|
643
|
+
if (prefix > 0) parts.push({ count: prefix });
|
|
644
|
+
for (const part of Diff.diffArrays(prevMid, newMid) as unknown as Array<{ count?: number; added?: boolean; removed?: boolean }>) {
|
|
645
|
+
parts.push({ count: part.count ?? 0, added: part.added, removed: part.removed });
|
|
646
|
+
}
|
|
647
|
+
if (suffix > 0) parts.push({ count: suffix });
|
|
658
648
|
return parts;
|
|
659
649
|
}
|
|
660
650
|
|
|
@@ -747,11 +737,11 @@ export function alignOwnership(
|
|
|
747
737
|
for (const anchor of prevAnchors) state.everMinted.add(anchor);
|
|
748
738
|
}
|
|
749
739
|
|
|
750
|
-
const parts:
|
|
740
|
+
const parts: ArrayPart[] = computeParts(prevChecksums, newChecksums);
|
|
751
741
|
let prevIdx = 0;
|
|
752
742
|
let newIdx = 0;
|
|
753
743
|
for (const part of parts) {
|
|
754
|
-
const count = part.count
|
|
744
|
+
const count = part.count;
|
|
755
745
|
if (part.added) {
|
|
756
746
|
for (let k = 0; k < count; k++) {
|
|
757
747
|
const checksum = newChecksums[newIdx + k]!;
|
|
@@ -821,9 +811,9 @@ export async function allocateFileAnchors(
|
|
|
821
811
|
const registry = current();
|
|
822
812
|
const shadow = options?.shadow === true;
|
|
823
813
|
const lines = splitLines(content);
|
|
824
|
-
const checksums = lines.map(
|
|
814
|
+
const checksums = lines.map(lineChecksum);
|
|
825
815
|
if (options?.previous?.spans) {
|
|
826
|
-
const prevChecksums = splitLines(options.previous.content).map(
|
|
816
|
+
const prevChecksums = splitLines(options.previous.content).map(lineChecksum);
|
|
827
817
|
const aligned = alignOwnershipWithSpans(path, options.previous.hashes, prevChecksums, checksums, options.previous.spans, { shadow });
|
|
828
818
|
if (!shadow && registry) registry.allocatedChecksum.set(path, contentChecksum(content));
|
|
829
819
|
if (!shadow && options.persist !== false) {
|
|
@@ -835,7 +825,7 @@ export async function allocateFileAnchors(
|
|
|
835
825
|
let prevChecksums: string[] | undefined;
|
|
836
826
|
if (options?.previous) {
|
|
837
827
|
prevAnchors = options.previous.hashes;
|
|
838
|
-
prevChecksums = splitLines(options.previous.content).map(
|
|
828
|
+
prevChecksums = splitLines(options.previous.content).map(lineChecksum);
|
|
839
829
|
} else {
|
|
840
830
|
const previousState = getAllocatedState(store, path, !shadow);
|
|
841
831
|
if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
|