pi-hashline-edit-pro 6.4.5 → 6.4.6
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 +2 -2
- package/index.ts +4 -10
- package/package.json +1 -1
- package/src/anchor-registry.ts +25 -3
- package/src/batch.ts +2 -2
- package/src/commit.ts +26 -14
- package/src/copy-move.ts +13 -2
- package/src/write-hook.ts +6 -2
package/README.md
CHANGED
|
@@ -482,12 +482,12 @@ Full reference:
|
|
|
482
482
|
| `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
|
|
483
483
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
|
|
484
484
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
485
|
-
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran
|
|
485
|
+
| `[E_REGISTRY]` | The anchor registry was not initialized, or the session's registry was released because the session was replaced or reloaded; a serve or edit ran without a usable session. Retry the operation in the current session. |
|
|
486
486
|
| `[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. |
|
|
487
487
|
| `[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). |
|
|
488
488
|
| `[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. |
|
|
489
489
|
| `[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. |
|
|
490
|
-
| `[E_OP_ABORTED]` |
|
|
490
|
+
| `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, 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 batch abort names the failing call and its error code when one is known. |
|
|
491
491
|
| `[E_GREP_FAILED]` | `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. |
|
|
492
492
|
| `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
|
|
493
493
|
|
package/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
setDisableOnModelsFromText,
|
|
34
34
|
} from "./src/config";
|
|
35
35
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
36
|
-
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
36
|
+
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, sessionContextReadable, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
37
37
|
import { serveRows } from "./src/served";
|
|
38
38
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
39
39
|
import { currentEditFlags } from "./src/edit-common";
|
|
@@ -154,14 +154,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
154
|
const key = sessionKeyFor(ctx);
|
|
155
155
|
const entry: PendingRegistrySession = {
|
|
156
156
|
key,
|
|
157
|
-
alive: () =>
|
|
158
|
-
try {
|
|
159
|
-
void ctx?.sessionManager;
|
|
160
|
-
return true;
|
|
161
|
-
} catch {
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
},
|
|
157
|
+
alive: () => sessionContextReadable(ctx),
|
|
165
158
|
};
|
|
166
159
|
const index = key === undefined ? -1 : pendingRegistrySessions.findIndex((candidate) => candidate.key === key);
|
|
167
160
|
if (index >= 0) pendingRegistrySessions[index] = entry;
|
|
@@ -174,7 +167,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
174
167
|
releaseRegistrySession(key);
|
|
175
168
|
}
|
|
176
169
|
|
|
177
|
-
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
170
|
+
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
|
|
171
|
+
if (!sessionContextReadable(ctx)) return undefined;
|
|
178
172
|
captureRegistrySession(ctx);
|
|
179
173
|
return withAnchorSession(ctx, fn);
|
|
180
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.4.
|
|
3
|
+
"version": "6.4.6",
|
|
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
|
@@ -406,7 +406,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
|
|
|
406
406
|
try {
|
|
407
407
|
await promise;
|
|
408
408
|
} finally {
|
|
409
|
-
pendingInits.delete(key);
|
|
409
|
+
if (pendingInits.get(key) === promise) pendingInits.delete(key);
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
|
|
@@ -435,6 +435,15 @@ function sessionFileFor(ctx: AnchorSessionContext | undefined): string | undefin
|
|
|
435
435
|
return typeof sessionFile === "string" && sessionFile.length > 0 ? sessionFile : undefined;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
export function sessionContextReadable(ctx: AnchorSessionContext | undefined): boolean {
|
|
439
|
+
try {
|
|
440
|
+
void ctx?.sessionManager;
|
|
441
|
+
return true;
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
438
447
|
export async function withAnchorSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
439
448
|
const key = sessionKeyFor(ctx);
|
|
440
449
|
if (key === undefined) return fn();
|
|
@@ -712,6 +721,9 @@ export function ownersForPath(path: string): Map<string, string> {
|
|
|
712
721
|
export function ensureRegistry(): void {
|
|
713
722
|
const key = activeKey();
|
|
714
723
|
if (key && registries.has(key)) return;
|
|
724
|
+
if (sessionScope.getStore() !== undefined) {
|
|
725
|
+
throw new Error("[E_REGISTRY] The anchor registry for this session is no longer available; the session was replaced or reloaded. Retry the operation in the new session.");
|
|
726
|
+
}
|
|
715
727
|
initRegistry(undefined).catch(() => undefined);
|
|
716
728
|
}
|
|
717
729
|
|
|
@@ -973,8 +985,18 @@ export async function allocateFileAnchors(
|
|
|
973
985
|
if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
|
|
974
986
|
prevAnchors = previousState.anchors;
|
|
975
987
|
prevChecksums = previousState.checksums;
|
|
976
|
-
if (!prevChecksums
|
|
977
|
-
|
|
988
|
+
if (!prevChecksums) {
|
|
989
|
+
if (previousState.contentChecksum === contentChecksum(content)) {
|
|
990
|
+
prevChecksums = checksums;
|
|
991
|
+
} else if (!shadow) {
|
|
992
|
+
const state = current();
|
|
993
|
+
const stale = state && prevAnchors ? prevAnchors.filter((anchor) => state.owned.get(anchor)?.path === path) : [];
|
|
994
|
+
if (state && stale.length > 0) {
|
|
995
|
+
const usedAt = state.lastUsedAt.get(path);
|
|
996
|
+
freeAnchors(path, stale);
|
|
997
|
+
if (usedAt !== undefined) state.lastUsedAt.set(path, usedAt);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
978
1000
|
}
|
|
979
1001
|
}
|
|
980
1002
|
}
|
package/src/batch.ts
CHANGED
|
@@ -505,7 +505,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
505
505
|
edits_noop: piece.noop ? 1 : 0,
|
|
506
506
|
warnings: 0,
|
|
507
507
|
classification: piece.noop ? "noop" : "applied",
|
|
508
|
-
...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
|
|
508
|
+
...(piece.noop ? {} : { added_lines: added, removed_lines: Math.max(0, piece.end - piece.start + 1 - piece.foldedLines) }),
|
|
509
509
|
};
|
|
510
510
|
const structuredContent: EditStructured = {
|
|
511
511
|
ok: true,
|
|
@@ -1102,7 +1102,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
1102
1102
|
let added = 0;
|
|
1103
1103
|
let removed = 0;
|
|
1104
1104
|
for (const piece of appliedPieces) {
|
|
1105
|
-
removed += piece.end - piece.start + 1;
|
|
1105
|
+
removed += Math.max(0, piece.end - piece.start + 1 - piece.foldedLines);
|
|
1106
1106
|
added += Math.max(0, piece.newLines.length - piece.foldedLines);
|
|
1107
1107
|
}
|
|
1108
1108
|
const verb = batchVerb(runtime);
|
package/src/commit.ts
CHANGED
|
@@ -14,6 +14,28 @@ import { spanForEdit } from "./replace";
|
|
|
14
14
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
15
15
|
import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
16
16
|
import { toEditVerb } from "./structured";
|
|
17
|
+
export type FileStateSinceRead = "unchanged" | "deleted" | "changed";
|
|
18
|
+
|
|
19
|
+
export async function fileStateSinceRead(
|
|
20
|
+
mutationTargetPath: string,
|
|
21
|
+
displayPath: string,
|
|
22
|
+
expectedNormalized: string,
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
): Promise<FileStateSinceRead> {
|
|
25
|
+
abortIf(signal);
|
|
26
|
+
let raw: string | undefined;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(mutationTargetPath, "utf-8");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const code = errCode(error);
|
|
31
|
+
if (code === "ENOENT") raw = undefined;
|
|
32
|
+
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${displayPath}`);
|
|
33
|
+
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${displayPath}`);
|
|
34
|
+
else throw mapIoError(error, "read", displayPath);
|
|
35
|
+
}
|
|
36
|
+
if (raw === undefined) return "deleted";
|
|
37
|
+
return toLF(stripBOM(raw).text) === expectedNormalized ? "unchanged" : "changed";
|
|
38
|
+
}
|
|
17
39
|
export interface CommitMeta {
|
|
18
40
|
editAnchors?: [string, string];
|
|
19
41
|
anchorCarry?: number;
|
|
@@ -91,21 +113,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
91
113
|
);
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
98
|
-
} catch (error) {
|
|
99
|
-
const code = errCode(error);
|
|
100
|
-
if (code === "ENOENT") currentRaw = undefined;
|
|
101
|
-
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
|
|
102
|
-
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
|
|
103
|
-
else throw mapIoError(error, "read", path);
|
|
104
|
-
}
|
|
105
|
-
if (currentRaw === undefined) {
|
|
116
|
+
const state = await fileStateSinceRead(mutationTargetPath, path, pipe.originalNormalized, signal);
|
|
117
|
+
if (state === "deleted") {
|
|
106
118
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
|
|
107
119
|
}
|
|
108
|
-
if (
|
|
120
|
+
if (state === "changed") {
|
|
109
121
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file changed after the edit started. Call read for fresh anchors and retry.`);
|
|
110
122
|
}
|
|
111
123
|
const undo = await saveUndo(mutationTargetPath, {
|
|
@@ -141,7 +153,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
141
153
|
firstChangedLine: pipe.firstChangedLine,
|
|
142
154
|
lastChangedLine: pipe.lastChangedLine,
|
|
143
155
|
addedLines: Math.max(0, pipe.totalAddedLines - (meta.foldedAnchorLines ?? 0)),
|
|
144
|
-
removedLines: pipe.totalRemovedLines,
|
|
156
|
+
removedLines: Math.max(0, pipe.totalRemovedLines - (meta.foldedAnchorLines ?? 0)),
|
|
145
157
|
};
|
|
146
158
|
|
|
147
159
|
let resultHashes: string[];
|
package/src/copy-move.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFil
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { constants } from "node:fs";
|
|
5
5
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
|
|
6
|
-
import { commitEdit } from "./commit";
|
|
6
|
+
import { commitEdit, fileStateSinceRead } from "./commit";
|
|
7
7
|
import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
|
|
8
8
|
import { preEditSnapshotFor } from "./pre-edit-snapshot";
|
|
9
9
|
import {
|
|
@@ -377,6 +377,17 @@ async function commitMovePair(input: {
|
|
|
377
377
|
warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
const destinationState = await fileStateSinceRead(input.destination.mutationTargetPath, input.destination.displayPath, input.destination.pipe.originalNormalized, input.signal);
|
|
381
|
+
if (destinationState !== "unchanged") {
|
|
382
|
+
const verb = destinationState === "deleted" ? "was deleted" : "changed";
|
|
383
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.destination.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
|
|
384
|
+
}
|
|
385
|
+
const sourceState = await fileStateSinceRead(input.source.mutationTargetPath, input.source.displayPath, input.source.pipe.originalNormalized, input.signal);
|
|
386
|
+
if (sourceState !== "unchanged") {
|
|
387
|
+
const verb = sourceState === "deleted" ? "was deleted" : "changed";
|
|
388
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.source.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
|
|
389
|
+
}
|
|
390
|
+
|
|
380
391
|
const undoDestination = await saveUndo(input.destination.mutationTargetPath, pairEntry(input.destination.pipe, destinationSeparators));
|
|
381
392
|
if (!undoDestination.persisted) {
|
|
382
393
|
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${input.destination.displayPath}. Nothing was written.`);
|
|
@@ -451,7 +462,7 @@ async function commitMovePair(input: {
|
|
|
451
462
|
firstChangedLine: input.destination.pipe.firstChangedLine,
|
|
452
463
|
lastChangedLine: input.destination.pipe.lastChangedLine,
|
|
453
464
|
addedLines: Math.max(0, input.destination.pipe.totalAddedLines - input.destination.foldedAnchorLines),
|
|
454
|
-
removedLines: input.destination.pipe.totalRemovedLines,
|
|
465
|
+
removedLines: Math.max(0, input.destination.pipe.totalRemovedLines - input.destination.foldedAnchorLines),
|
|
455
466
|
},
|
|
456
467
|
...(destinationSpan !== undefined ? { spans: [destinationSpan] } : {}),
|
|
457
468
|
}, "moved", contextLines);
|
package/src/write-hook.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
3
|
import { HASH_SEP } from "./hashline/hash";
|
|
4
|
-
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
4
|
+
import { sessionContextReadable, servedForPath, withAnchorSession, type AnchorSessionContext } from "./anchor-registry";
|
|
5
5
|
import { resolveInCwd } from "./fs-write";
|
|
6
6
|
import { abortIf, splitLines, isRec } from "./utils";
|
|
7
7
|
import type { ModelLike } from "./model-gate";
|
|
@@ -31,8 +31,12 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
|
|
|
31
31
|
return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} contains the copied ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry.`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function withReadableSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
|
|
35
|
+
return sessionContextReadable(ctx) ? withAnchorSession(ctx, fn) : Promise.resolve(undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export function registerWriteHook(pi: ExtensionAPI, isModelDisabled?: (model: ModelLike | undefined) => boolean): void {
|
|
35
|
-
pi.on("tool_call", async (event, ctx) =>
|
|
39
|
+
pi.on("tool_call", async (event, ctx) => withReadableSession(ctx, async () => {
|
|
36
40
|
if (event.toolName !== "write") return;
|
|
37
41
|
if (isModelDisabled?.(ctx.model)) return;
|
|
38
42
|
const input = event.input as Record<string, unknown> | undefined;
|