pi-hashline-edit-pro 6.4.7 → 6.4.8
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 +1 -1
- package/package.json +9 -9
- package/src/anchor-registry.ts +33 -9
- package/src/batch.ts +2 -2
- package/src/copy-move.ts +8 -4
- package/src/edit-common.ts +8 -4
- package/src/insert.ts +11 -4
- package/src/read.ts +71 -35
- package/src/replace-diff.ts +38 -15
- package/src/replace.ts +6 -3
- package/src/utils.ts +22 -2
package/README.md
CHANGED
|
@@ -422,7 +422,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
422
422
|
|
|
423
423
|
All eight tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
424
424
|
|
|
425
|
-
All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text.
|
|
425
|
+
All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. In a codemode session `tools.read()` resolves to the file's text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }`, where `text` is the raw file content of the served window and `lines` carries the anchored rows. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
|
|
426
426
|
|
|
427
427
|
`read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors, notices }`, where `text` is the raw file content of the served window (no anchors, no footers) and each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` there is what was shown, so an oversized line appears as its marker while keeping its anchor). `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`. An image read returns `{ type: "image", data, mimeType, note }`.
|
|
428
428
|
|
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.8",
|
|
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",
|
|
@@ -61,15 +61,15 @@
|
|
|
61
61
|
"typecheck": "tsc --noEmit"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
-
"@types/node": "^24",
|
|
67
|
-
"@vitest/coverage-v8": "^
|
|
68
|
-
"eslint": "^10.
|
|
69
|
-
"typebox": "^1.
|
|
70
|
-
"typescript": "^
|
|
71
|
-
"typescript-eslint": "^8.
|
|
72
|
-
"vitest": "^
|
|
66
|
+
"@types/node": "^24.19.2",
|
|
67
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
68
|
+
"eslint": "^10.13.0",
|
|
69
|
+
"typebox": "^1.4.1",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"typescript-eslint": "^8.71.2",
|
|
72
|
+
"vitest": "^5.0.5"
|
|
73
73
|
},
|
|
74
74
|
"allowScripts": {
|
|
75
75
|
"@google/genai@1.52.0": true,
|
package/src/anchor-registry.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
6
6
|
import { sessionClaimsDir } from "./paths";
|
|
7
7
|
import { contentChecksum } from "./hashline/hasher";
|
|
8
8
|
import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
|
|
9
|
-
import { HASH_PROBE_STRIDE } from "./hashline/hash";
|
|
10
|
-
import { errCode, isRec, splitLines } from "./utils";
|
|
9
|
+
import { HASH_PROBE_STRIDE, MAX_HASH_LINES } from "./hashline/hash";
|
|
10
|
+
import { assertLineCount, errCode, isRec, splitLines, spliceAll } 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";
|
|
@@ -46,6 +46,7 @@ interface OwnedAnchorMap {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
interface AnchorMintedSet {
|
|
49
|
+
readonly size: number;
|
|
49
50
|
has(anchor: string): boolean;
|
|
50
51
|
add(anchor: string): unknown;
|
|
51
52
|
[Symbol.iterator](): IterableIterator<string>;
|
|
@@ -125,9 +126,12 @@ class ShadowOwnedMap implements OwnedAnchorMap {
|
|
|
125
126
|
class ShadowMintedSet implements AnchorMintedSet {
|
|
126
127
|
private readonly parent: AnchorMintedSet;
|
|
127
128
|
private readonly added = new Set<string>();
|
|
129
|
+
private extra = 0;
|
|
130
|
+
private parentSize: number;
|
|
128
131
|
|
|
129
132
|
constructor(parent: AnchorMintedSet) {
|
|
130
133
|
this.parent = parent;
|
|
134
|
+
this.parentSize = parent.size;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
has(anchor: string): boolean {
|
|
@@ -135,10 +139,22 @@ class ShadowMintedSet implements AnchorMintedSet {
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
add(anchor: string): this {
|
|
142
|
+
if (!this.added.has(anchor) && !this.parent.has(anchor)) this.extra += 1;
|
|
138
143
|
this.added.add(anchor);
|
|
139
144
|
return this;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
get size(): number {
|
|
148
|
+
if (this.parent.size !== this.parentSize) {
|
|
149
|
+
this.parentSize = this.parent.size;
|
|
150
|
+
this.extra = 0;
|
|
151
|
+
for (const anchor of this.added) {
|
|
152
|
+
if (!this.parent.has(anchor)) this.extra += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return this.parentSize + this.extra;
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
*[Symbol.iterator](): IterableIterator<string> {
|
|
143
159
|
yield* this.parent;
|
|
144
160
|
for (const anchor of this.added) {
|
|
@@ -555,16 +571,19 @@ export function reclaimAnchorSpace(state: SessionState, protectPath?: string): s
|
|
|
555
571
|
|
|
556
572
|
export function mintAnchor(state: SessionState, protectPath?: string): string {
|
|
557
573
|
for (;;) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
574
|
+
if (state.everMinted.size < ANCHOR_COUNT) {
|
|
575
|
+
for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
|
|
576
|
+
state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
|
|
577
|
+
const candidate = anchorAt(state.probe);
|
|
578
|
+
if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
|
|
579
|
+
return candidate;
|
|
580
|
+
}
|
|
563
581
|
}
|
|
564
582
|
}
|
|
565
583
|
for (let step = 1; step <= ANCHOR_COUNT; step++) {
|
|
566
584
|
const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
|
|
567
585
|
if (!state.owned.has(candidate)) {
|
|
586
|
+
state.probe = (state.probe + step) % ANCHOR_COUNT;
|
|
568
587
|
for (const served of state.served.values()) served.delete(candidate);
|
|
569
588
|
return candidate;
|
|
570
589
|
}
|
|
@@ -861,7 +880,7 @@ export function alignOwnershipWithSpans(
|
|
|
861
880
|
state.everMinted.add(settled[k]!);
|
|
862
881
|
state.owned.set(settled[k]!, { path, checksum: newChecksums[start + k]! });
|
|
863
882
|
}
|
|
864
|
-
anchors
|
|
883
|
+
spliceAll(anchors, start, spanLength, settled);
|
|
865
884
|
offset += span.replacementCount - spanLength;
|
|
866
885
|
}
|
|
867
886
|
const rows: [string, string][] = minted.map((m) => [m.anchor, m.checksum]);
|
|
@@ -963,8 +982,9 @@ export async function allocateFileAnchors(
|
|
|
963
982
|
ensureRegistry();
|
|
964
983
|
const registry = current();
|
|
965
984
|
const shadow = options?.shadow === true;
|
|
966
|
-
if (!shadow && registry) touchPath(registry, path);
|
|
967
985
|
const lines = splitLines(content);
|
|
986
|
+
assertLineCount(lines.length, path, MAX_HASH_LINES);
|
|
987
|
+
if (!shadow && registry) touchPath(registry, path);
|
|
968
988
|
const checksums = lines.map(lineChecksum);
|
|
969
989
|
if (options?.previous?.spans) {
|
|
970
990
|
const prevChecksums = splitLines(options.previous.content).map(lineChecksum);
|
|
@@ -1058,6 +1078,10 @@ export function resetRegistryForTests(): void {
|
|
|
1058
1078
|
registries.clear();
|
|
1059
1079
|
}
|
|
1060
1080
|
|
|
1081
|
+
export function lastUsedAtForTests(path: string): number | undefined {
|
|
1082
|
+
return current()?.lastUsedAt.get(path);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1061
1085
|
async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
|
|
1062
1086
|
const handle = await open(sidecar, "r");
|
|
1063
1087
|
const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
|
package/src/batch.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { saveUndo } from "./replace-undo";
|
|
|
28
28
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
29
29
|
import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
|
|
30
30
|
import { serveRows, servedHashesFromDiff } from "./served";
|
|
31
|
-
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines } from "./utils";
|
|
31
|
+
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines, spliceAll } from "./utils";
|
|
32
32
|
|
|
33
33
|
export interface PlannedMember {
|
|
34
34
|
batchKey: number;
|
|
@@ -793,7 +793,7 @@ function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
|
|
|
793
793
|
const lines = splitLines(baseContent);
|
|
794
794
|
const baseLineCount = lines.length;
|
|
795
795
|
const descending = [...pieces].sort((a, b) => b.start - a.start);
|
|
796
|
-
for (const piece of descending) lines
|
|
796
|
+
for (const piece of descending) spliceAll(lines, piece.start - 1, piece.end - piece.start + 1, piece.newLines);
|
|
797
797
|
let composed = lines.join("\n");
|
|
798
798
|
if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) {
|
|
799
799
|
composed += "\n";
|
package/src/copy-move.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAncho
|
|
|
23
23
|
import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure, pendingBatchMemberFor, registerBatchPlanner, type BatchBase, type BatchMemberPlanner, type PlannedMember } from "./batch";
|
|
24
24
|
import { loadP, loadGuidelines } from "./prompts";
|
|
25
25
|
import { assertTransferReq, normReq, type TransferReq } from "./payload-contract";
|
|
26
|
-
import { abortIf, assertByteLimit, isRec, splitLines } from "./utils";
|
|
26
|
+
import { abortIf, anchorCarryIndex, assertByteLimit, isRec, splitLines } from "./utils";
|
|
27
27
|
import { toDisplayPath } from "./paths";
|
|
28
28
|
import { getDiffContextLines } from "./config";
|
|
29
29
|
import { resolveInCwd, writeAtomic } from "./fs-write";
|
|
@@ -145,7 +145,7 @@ export function buildTransferEdit(input: {
|
|
|
145
145
|
: [...sourceLines, fileLines[anchorLine - 1]!],
|
|
146
146
|
},
|
|
147
147
|
foldedAnchorLines: 1,
|
|
148
|
-
anchorCarry: refs.direction
|
|
148
|
+
anchorCarry: anchorCarryIndex(refs.direction, sourceLines.length),
|
|
149
149
|
endingOverrides: refs.direction === "after"
|
|
150
150
|
? [undefined, ...sourceEndings]
|
|
151
151
|
: [...sourceEndings, undefined],
|
|
@@ -319,14 +319,14 @@ async function prepareCrossTransfer(input: {
|
|
|
319
319
|
? offsetEndings(1, sourceEndings)
|
|
320
320
|
: sourceEndings;
|
|
321
321
|
if (kind === "copy") {
|
|
322
|
-
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction
|
|
322
|
+
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: anchorCarryIndex(refs.direction, moved.length) } : {}), endingOverrides };
|
|
323
323
|
}
|
|
324
324
|
const sourceEdit: HTEdit = {
|
|
325
325
|
remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
|
|
326
326
|
remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
|
|
327
327
|
text: [],
|
|
328
328
|
};
|
|
329
|
-
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction
|
|
329
|
+
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: anchorCarryIndex(refs.direction, moved.length) } : {}), sourceEdit, endingOverrides };
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
function pairEntry(pipe: PipelineResult, separators: LineEnding[] | undefined): UndoEntry {
|
|
@@ -584,6 +584,7 @@ async function executeCrossFile(
|
|
|
584
584
|
signal,
|
|
585
585
|
preloadedNorm: prepared.destinationPreload,
|
|
586
586
|
endingOverrides: prepared.endingOverrides,
|
|
587
|
+
...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
|
|
587
588
|
});
|
|
588
589
|
if (kind === "copy") {
|
|
589
590
|
return commitEdit(destinationPipe, {
|
|
@@ -827,6 +828,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
827
828
|
served: plan.servedOverride,
|
|
828
829
|
endingOverrides: plan.endingOverrides,
|
|
829
830
|
signal,
|
|
831
|
+
...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
|
|
830
832
|
});
|
|
831
833
|
return previewFromPipe(pipe);
|
|
832
834
|
}
|
|
@@ -837,6 +839,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
837
839
|
preloadedNorm: prepared.destinationPreload,
|
|
838
840
|
endingOverrides: prepared.endingOverrides,
|
|
839
841
|
signal,
|
|
842
|
+
...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
|
|
840
843
|
});
|
|
841
844
|
if (kind === "copy") return previewFromPipe(destinationPipe);
|
|
842
845
|
const sourcePipe = await execPipeline(sourcePath, prepared.sourceEdit!, cwd, {
|
|
@@ -1017,6 +1020,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
|
|
|
1017
1020
|
preloadedNorm: preload,
|
|
1018
1021
|
served: plan.servedOverride,
|
|
1019
1022
|
endingOverrides: plan.endingOverrides,
|
|
1023
|
+
...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
|
|
1020
1024
|
});
|
|
1021
1025
|
return commitEdit(pipe, {
|
|
1022
1026
|
path: pipe.path,
|
package/src/edit-common.ts
CHANGED
|
@@ -53,10 +53,14 @@ function preferenceGuideline(flags: EditToolFlags): string {
|
|
|
53
53
|
const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
|
|
54
54
|
const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
|
|
55
55
|
const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
|
|
56
|
-
const
|
|
57
|
-
|
|
56
|
+
const SHARED_SCRIPT_OPS = ["read", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
|
|
57
|
+
|
|
58
|
+
function resultContractGuideline(flags: EditToolFlags): string {
|
|
59
|
+
const tools = operationNames(SHARED_SCRIPT_OPS, flags);
|
|
60
|
+
return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`.`;
|
|
61
|
+
}
|
|
58
62
|
const READ_SCRIPT_GUIDELINE =
|
|
59
|
-
'`read
|
|
63
|
+
'`read` in a codemode script resolves to the file\'s text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }` with the raw file content and `lines[].anchor` for chained edits. An image read resolves to an image block.';
|
|
60
64
|
const GREP_SCRIPT_GUIDELINE =
|
|
61
65
|
'`grep`: a codemode script receives `{ text, results }`; each `results[].lines[]` entry carries the matching line\'s `anchor` and `text`, so a result is editable without a fresh read.';
|
|
62
66
|
function editScriptGuideline(flags: EditToolFlags): string {
|
|
@@ -137,7 +141,7 @@ function readGuidelineApplies(guideline: Guideline, flags: EditToolFlags): boole
|
|
|
137
141
|
|
|
138
142
|
export function withReadPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
139
143
|
const preference = preferenceGuideline(flags);
|
|
140
|
-
const script = flags.codemode ? [READ_SCRIPT_GUIDELINE,
|
|
144
|
+
const script = flags.codemode ? [READ_SCRIPT_GUIDELINE, resultContractGuideline(flags)] : [];
|
|
141
145
|
const applicable = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
|
|
142
146
|
if (flags.autoReadAllActive) {
|
|
143
147
|
const rewritten = applicable.filter((guideline) => guideline.id !== READ_REREAD_ID).map((guideline) => guideline.text);
|
package/src/insert.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { stripAnchorRow } from "./hashline/resolve";
|
|
|
12
12
|
import { withAnchorSession } from "./anchor-registry";
|
|
13
13
|
import { loadP, loadGuidelines } from "./prompts";
|
|
14
14
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
15
|
-
import { isRec, literalEscapeHints, splitLines } from "./utils";
|
|
15
|
+
import { anchorCarryIndex, isRec, literalEscapeHints, splitLines } from "./utils";
|
|
16
16
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
17
17
|
import type { RPreview, RRState } from "./replace-render";
|
|
18
18
|
import { editResultSchema, withStructuredErrors } from "./structured";
|
|
@@ -98,6 +98,11 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
|
|
|
98
98
|
return { label: "text", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function insertAnchorCarry(direction: "before" | "after", editParams: HTEdit, anchorLine: string | undefined): number | undefined {
|
|
102
|
+
if (anchorLine === undefined) return undefined;
|
|
103
|
+
return anchorCarryIndex(direction, editParams.text.length - 1);
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
function planInsertBatchMember(input: BatchPlannerInput): BatchMemberPlan {
|
|
102
107
|
const req = normReq(input.member.args);
|
|
103
108
|
assertInsertReq(req);
|
|
@@ -149,6 +154,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
149
154
|
signal,
|
|
150
155
|
});
|
|
151
156
|
const { editParams, anchorLine, contentSeparators } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
157
|
+
const anchorCarry = insertAnchorCarry(normalized.direction, editParams, anchorLine);
|
|
152
158
|
const pipe = await execPipeline(targetPath, editParams, cwd, {
|
|
153
159
|
accessMode: constants.R_OK,
|
|
154
160
|
noPersist: true,
|
|
@@ -156,6 +162,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
156
162
|
signal,
|
|
157
163
|
stripWarning: insertStripWarning(anchorLine, normalized.direction),
|
|
158
164
|
endingOverrides: contentSeparators,
|
|
165
|
+
...(anchorCarry !== undefined ? { anchorCarry } : {}),
|
|
159
166
|
});
|
|
160
167
|
return previewFromPipe(pipe);
|
|
161
168
|
} catch (error: unknown) {
|
|
@@ -241,21 +248,21 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
241
248
|
maxLines: MAX_HASH_LINES,
|
|
242
249
|
});
|
|
243
250
|
const { editParams, anchorLine, contentSeparators } = buildInsertEdit(req, preload, ref, targetPath);
|
|
251
|
+
const anchorCarry = insertAnchorCarry(req.direction, editParams, anchorLine);
|
|
244
252
|
const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
|
|
245
253
|
accessMode: constants.R_OK | constants.W_OK,
|
|
246
254
|
signal,
|
|
247
255
|
preloadedNorm: preload,
|
|
248
256
|
stripWarning: insertStripWarning(anchorLine, req.direction),
|
|
249
257
|
endingOverrides: contentSeparators,
|
|
258
|
+
...(anchorCarry !== undefined ? { anchorCarry } : {}),
|
|
250
259
|
});
|
|
251
260
|
return commitEdit(pipe, {
|
|
252
261
|
path: pipe.path,
|
|
253
262
|
absolutePath,
|
|
254
263
|
mutationTargetPath,
|
|
255
264
|
editAnchors: [editParams.remove_from, editParams.remove_to],
|
|
256
|
-
...(
|
|
257
|
-
? {}
|
|
258
|
-
: { anchorCarry: req.direction === "after" ? 0 : editParams.text.length - 1 }),
|
|
265
|
+
...(anchorCarry !== undefined ? { anchorCarry } : {}),
|
|
259
266
|
signal,
|
|
260
267
|
verb: "inserted",
|
|
261
268
|
noopNoun: "Insertion",
|
package/src/read.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { withAnchorSession, adoptAnchors, ownerOf, ownersDifferingOnlyByCase, fo
|
|
|
23
23
|
import { serveRows } from "./served";
|
|
24
24
|
import { snapshotCache } from "./hash-store/cache";
|
|
25
25
|
import { Text } from "@earendil-works/pi-tui";
|
|
26
|
-
import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult } from "./structured";
|
|
26
|
+
import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult, type ReadTextResult } from "./structured";
|
|
27
27
|
const R_DESC = loadP("../tool-prompts/read.md");
|
|
28
28
|
const R_SNIPPET = loadP("../tool-prompts/read-snippet.md");
|
|
29
29
|
function readGuide(): Guideline[] {
|
|
@@ -241,7 +241,6 @@ function anchorRefOf(args: unknown): { anchor: string; limit: number | undefined
|
|
|
241
241
|
const { offset, limit, path } = args;
|
|
242
242
|
if (typeof offset !== "string" || offset.length === 0 || /^\d+$/.test(offset)) return undefined;
|
|
243
243
|
const validLimit = typeof limit === "number" && Number.isInteger(limit) && limit >= 1 ? limit : undefined;
|
|
244
|
-
if (limit !== undefined && validLimit === undefined) return undefined;
|
|
245
244
|
return { anchor: offset, limit: validLimit, path: typeof path === "string" ? path : undefined };
|
|
246
245
|
}
|
|
247
246
|
|
|
@@ -263,28 +262,79 @@ function anchorLineOf(anchor: string, path: string | undefined, cwd: unknown): n
|
|
|
263
262
|
return index < 0 ? undefined : index + 1;
|
|
264
263
|
}
|
|
265
264
|
|
|
265
|
+
const ANSI_ESC = String.fromCharCode(27);
|
|
266
|
+
const CALL_ANSI_RE = new RegExp(`${ANSI_ESC}\\[[0-9;]*m`, "g");
|
|
267
|
+
const CALL_ANSI_AT_RE = new RegExp(`^${ANSI_ESC}\\[[0-9;]*m`);
|
|
268
|
+
|
|
269
|
+
function rawIndexForPlainIndex(text: string, plainIndex: number): number {
|
|
270
|
+
let raw = 0;
|
|
271
|
+
let plain = 0;
|
|
272
|
+
while (raw < text.length && plain < plainIndex) {
|
|
273
|
+
const escape = CALL_ANSI_AT_RE.exec(text.slice(raw));
|
|
274
|
+
if (escape !== null) {
|
|
275
|
+
raw += escape[0].length;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
raw += 1;
|
|
279
|
+
plain += 1;
|
|
280
|
+
}
|
|
281
|
+
return raw;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function insertBeforeExpandHint(text: string, suffix: string): string {
|
|
285
|
+
const plain = text.replace(CALL_ANSI_RE, "");
|
|
286
|
+
if (!plain.endsWith(" to expand)")) return `${text}${suffix}`;
|
|
287
|
+
const plainIndex = plain.lastIndexOf(" (");
|
|
288
|
+
const insertAt = plainIndex < 0 ? text.length : rawIndexForPlainIndex(text, plainIndex);
|
|
289
|
+
return `${text.slice(0, insertAt)}${suffix}${text.slice(insertAt)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function anchorRangeLabel(ref: { anchor: string; limit: number | undefined }, line: number | undefined): string {
|
|
293
|
+
if (line === undefined) return ref.limit === undefined ? `:${ref.anchor}` : `:${ref.anchor} +${ref.limit}`;
|
|
294
|
+
return ref.limit === undefined ? `:${line} (${ref.anchor})` : `:${line}-${line + ref.limit - 1} (${ref.anchor})`;
|
|
295
|
+
}
|
|
296
|
+
|
|
266
297
|
function renderReadCall(args: unknown, theme: any, context: any): Text {
|
|
267
298
|
const normalized = normalizeReadCallArgs(args);
|
|
268
|
-
const rendered = builtinReadRenderCall(normalized, theme, context);
|
|
269
299
|
const ref = anchorRefOf(normalized);
|
|
270
|
-
if (ref === undefined) return
|
|
271
|
-
const
|
|
272
|
-
const text = (rendered as unknown as { text: string }).text;
|
|
273
|
-
const index = text.lastIndexOf(marker);
|
|
274
|
-
if (index < 0) return rendered;
|
|
300
|
+
if (ref === undefined) return builtinReadRenderCall(normalized, theme, context);
|
|
301
|
+
const rendered = builtinReadRenderCall(isRec(normalized) ? { ...normalized, offset: undefined, limit: undefined } : normalized, theme, context);
|
|
275
302
|
const line = anchorLineOf(ref.anchor, ref.path, context?.cwd);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
|
|
280
|
-
return rendered;
|
|
281
|
-
}
|
|
282
|
-
const range = ref.limit === undefined ? `${line}` : `${line}-${line + ref.limit - 1}`;
|
|
283
|
-
const replacement = theme.fg("warning", `:${range} (${ref.anchor})`);
|
|
284
|
-
rendered.setText(`${text.slice(0, index)}${replacement}${text.slice(index + marker.length)}`);
|
|
303
|
+
const suffix = theme.fg("warning", anchorRangeLabel(ref, line));
|
|
304
|
+
const text = (rendered as unknown as { text: string }).text;
|
|
305
|
+
rendered.setText(insertBeforeExpandHint(text, suffix));
|
|
285
306
|
return rendered;
|
|
286
307
|
}
|
|
287
308
|
|
|
309
|
+
function buildReadToolSchema(codemode: boolean) {
|
|
310
|
+
return Type.Object({
|
|
311
|
+
path: Type.String({
|
|
312
|
+
description: "Path to the file to read (relative or absolute)",
|
|
313
|
+
}),
|
|
314
|
+
offset: Type.Optional(
|
|
315
|
+
Type.Union([
|
|
316
|
+
Type.Integer({ minimum: 1 }),
|
|
317
|
+
Type.String(),
|
|
318
|
+
], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
|
|
319
|
+
),
|
|
320
|
+
limit: Type.Optional(
|
|
321
|
+
Type.Integer({
|
|
322
|
+
minimum: 1,
|
|
323
|
+
description: "Maximum number of lines to read from offset",
|
|
324
|
+
}),
|
|
325
|
+
),
|
|
326
|
+
...(codemode
|
|
327
|
+
? {
|
|
328
|
+
anchors: Type.Optional(
|
|
329
|
+
Type.Boolean({
|
|
330
|
+
description: "Return { text, lines, notices } with editable anchors instead of the plain file text to a codemode script",
|
|
331
|
+
}),
|
|
332
|
+
),
|
|
333
|
+
}
|
|
334
|
+
: {}),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
288
338
|
export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
289
339
|
const prompted = withReadPrompts({ description: R_DESC, snippet: R_SNIPPET, guidelines: readGuide() }, flags);
|
|
290
340
|
pi.registerTool({
|
|
@@ -294,23 +344,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
294
344
|
promptSnippet: prompted.snippet,
|
|
295
345
|
promptGuidelines: prompted.guidelines,
|
|
296
346
|
prepareArguments: makePrepareArguments(),
|
|
297
|
-
parameters:
|
|
298
|
-
path: Type.String({
|
|
299
|
-
description: "Path to the file to read (relative or absolute)",
|
|
300
|
-
}),
|
|
301
|
-
offset: Type.Optional(
|
|
302
|
-
Type.Union([
|
|
303
|
-
Type.Integer({ minimum: 1 }),
|
|
304
|
-
Type.String(),
|
|
305
|
-
], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
|
|
306
|
-
),
|
|
307
|
-
limit: Type.Optional(
|
|
308
|
-
Type.Integer({
|
|
309
|
-
minimum: 1,
|
|
310
|
-
description: "Maximum number of lines to read from offset",
|
|
311
|
-
}),
|
|
312
|
-
),
|
|
313
|
-
}),
|
|
347
|
+
parameters: buildReadToolSchema(flags.codemode),
|
|
314
348
|
outputSchema: readResultSchema,
|
|
315
349
|
executionMode: "sequential",
|
|
316
350
|
renderCall: renderReadCall,
|
|
@@ -370,7 +404,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
370
404
|
const servedRawText = preview.anchoredLines.map((row) => fileLines[row.line - 1] ?? "").join("\n");
|
|
371
405
|
const rawTruncation = servedRawText.length > 0 ? truncateHead(servedRawText, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }) : undefined;
|
|
372
406
|
|
|
373
|
-
const
|
|
407
|
+
const readResult: ReadTextResult = {
|
|
374
408
|
ok: true,
|
|
375
409
|
kind: "read",
|
|
376
410
|
path: rawPath,
|
|
@@ -384,6 +418,8 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
384
418
|
hadUtf8DecodeErrors,
|
|
385
419
|
notices,
|
|
386
420
|
};
|
|
421
|
+
const anchorsRequested = isRec(params) && params.anchors === true;
|
|
422
|
+
const structuredContent: ReadResult = flags.codemode && !anchorsRequested ? readResult.text : readResult;
|
|
387
423
|
return {
|
|
388
424
|
content: [{ type: "text", text: previewText }],
|
|
389
425
|
details: {
|
package/src/replace-diff.ts
CHANGED
|
@@ -159,6 +159,40 @@ function placeSpans(
|
|
|
159
159
|
return placed;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
function splitPlacedSpan(
|
|
163
|
+
span: PlacedSpan,
|
|
164
|
+
oldLines: string[],
|
|
165
|
+
newLines: string[],
|
|
166
|
+
oldHashes: string[],
|
|
167
|
+
newIndexByHash: ReadonlyMap<string, number>,
|
|
168
|
+
): PlacedSpan[] {
|
|
169
|
+
if (span.oldStart > span.oldEnd || span.newStart > span.newEnd) return [span];
|
|
170
|
+
const pairs: Array<[number, number]> = [];
|
|
171
|
+
let lastNew = span.newStart - 1;
|
|
172
|
+
for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd; oldIdx++) {
|
|
173
|
+
const newIdx = newIndexByHash.get(oldHashes[oldIdx]!);
|
|
174
|
+
if (newIdx === undefined || newIdx < span.newStart || newIdx > span.newEnd || newIdx <= lastNew) continue;
|
|
175
|
+
if (oldLines[oldIdx] !== newLines[newIdx]) continue;
|
|
176
|
+
pairs.push([oldIdx, newIdx]);
|
|
177
|
+
lastNew = newIdx;
|
|
178
|
+
}
|
|
179
|
+
if (pairs.length === 0) return [span];
|
|
180
|
+
const out: PlacedSpan[] = [];
|
|
181
|
+
let oldCursor = span.oldStart;
|
|
182
|
+
let newCursor = span.newStart;
|
|
183
|
+
for (const [oldIdx, newIdx] of pairs) {
|
|
184
|
+
if (oldCursor <= oldIdx - 1 || newCursor <= newIdx - 1) {
|
|
185
|
+
out.push({ oldStart: oldCursor, oldEnd: oldIdx - 1, newStart: newCursor, newEnd: newIdx - 1 });
|
|
186
|
+
}
|
|
187
|
+
oldCursor = oldIdx + 1;
|
|
188
|
+
newCursor = newIdx + 1;
|
|
189
|
+
}
|
|
190
|
+
if (oldCursor <= span.oldEnd || newCursor <= span.newEnd) {
|
|
191
|
+
out.push({ oldStart: oldCursor, oldEnd: span.oldEnd, newStart: newCursor, newEnd: span.newEnd });
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
162
196
|
function trimPlacedSpans(
|
|
163
197
|
placed: PlacedSpan[],
|
|
164
198
|
oldLines: string[],
|
|
@@ -166,22 +200,11 @@ function trimPlacedSpans(
|
|
|
166
200
|
oldHashes: string[],
|
|
167
201
|
newHashes: string[],
|
|
168
202
|
): PlacedSpan[] {
|
|
203
|
+
const newIndexByHash = new Map<string, number>();
|
|
204
|
+
for (let index = 0; index < newHashes.length; index++) newIndexByHash.set(newHashes[index]!, index);
|
|
169
205
|
const out: PlacedSpan[] = [];
|
|
170
206
|
for (const span of placed) {
|
|
171
|
-
|
|
172
|
-
let oldEnd = span.oldEnd;
|
|
173
|
-
let newStart = span.newStart;
|
|
174
|
-
let newEnd = span.newEnd;
|
|
175
|
-
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
|
|
176
|
-
oldStart += 1;
|
|
177
|
-
newStart += 1;
|
|
178
|
-
}
|
|
179
|
-
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
|
|
180
|
-
oldEnd -= 1;
|
|
181
|
-
newEnd -= 1;
|
|
182
|
-
}
|
|
183
|
-
if (oldStart > oldEnd && newStart > newEnd) continue;
|
|
184
|
-
out.push({ oldStart, oldEnd, newStart, newEnd });
|
|
207
|
+
for (const piece of splitPlacedSpan(span, oldLines, newLines, oldHashes, newIndexByHash)) out.push(piece);
|
|
185
208
|
}
|
|
186
209
|
return out;
|
|
187
210
|
}
|
|
@@ -364,7 +387,7 @@ export function genDiff(
|
|
|
364
387
|
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
|
|
365
388
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
366
389
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
367
|
-
if (spans && newContentHashes && oldContentHashes) {
|
|
390
|
+
if (spans && spans.length > 0 && newContentHashes && oldContentHashes) {
|
|
368
391
|
const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
|
|
369
392
|
if (anchored) {
|
|
370
393
|
const diff = disambiguateDuplicateAnchors(anchored.diff);
|
package/src/replace.ts
CHANGED
|
@@ -87,6 +87,7 @@ export interface ExecPipelineOptions {
|
|
|
87
87
|
stripWarning?: StripWarningLocation;
|
|
88
88
|
endingOverrides?: (LineEnding | undefined)[];
|
|
89
89
|
preserveDeletionSeparators?: boolean;
|
|
90
|
+
anchorCarry?: number;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
@@ -205,20 +206,22 @@ export async function execPipeline(
|
|
|
205
206
|
const result = anchorResult.content;
|
|
206
207
|
const isNoop = result === originalNormalized;
|
|
207
208
|
|
|
209
|
+
const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
|
|
210
|
+
if (pipeSpan && options?.anchorCarry !== undefined) pipeSpan.carry = options.anchorCarry;
|
|
211
|
+
const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
|
|
212
|
+
|
|
208
213
|
const resultHashes = isNoop
|
|
209
214
|
? originalHashes
|
|
210
215
|
: await lineHashes(result, absolutePath, {
|
|
211
216
|
content: originalNormalized,
|
|
212
217
|
hashes: originalHashes,
|
|
218
|
+
...(pipeSpans ? { spans: pipeSpans } : {}),
|
|
213
219
|
}, hashStore, false, true);
|
|
214
220
|
const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
|
|
215
221
|
await throwIfStrictInput(warnings);
|
|
216
222
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
217
223
|
effectiveEdit, originalHashes, isNoop,
|
|
218
224
|
);
|
|
219
|
-
|
|
220
|
-
const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
|
|
221
|
-
const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
|
|
222
225
|
return {
|
|
223
226
|
path: displayPath,
|
|
224
227
|
originalNormalized,
|
package/src/utils.ts
CHANGED
|
@@ -41,6 +41,24 @@ export function splitLines(text: string): string[] {
|
|
|
41
41
|
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export function spliceAll<T>(target: T[], start: number, deleteCount: number, items: readonly T[]): T[] {
|
|
45
|
+
const rawStart = Number.isNaN(start) ? 0 : Math.trunc(start);
|
|
46
|
+
const at = rawStart < 0 ? Math.max(target.length + rawStart, 0) : Math.min(rawStart, target.length);
|
|
47
|
+
const rawCount = Number.isNaN(deleteCount) ? 0 : Math.trunc(deleteCount);
|
|
48
|
+
const remove = Math.max(0, Math.min(rawCount, target.length - at));
|
|
49
|
+
const removed = target.slice(at, at + remove);
|
|
50
|
+
const tail = target.slice(at + remove);
|
|
51
|
+
const additions: readonly T[] = items === target ? [...items] : items;
|
|
52
|
+
target.length = at;
|
|
53
|
+
for (const item of additions) target.push(item);
|
|
54
|
+
for (const item of tail) target.push(item);
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function anchorCarryIndex(direction: "after" | "before", lineCount: number): number {
|
|
59
|
+
return direction === "after" ? 0 : lineCount;
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
export function visLines(text: string): string[] {
|
|
45
63
|
return text.length === 0 ? [] : splitLines(text);
|
|
46
64
|
}
|
|
@@ -194,10 +212,12 @@ export function clipLine(line: string, maxLen = 200): string {
|
|
|
194
212
|
const flat = line.replace(/\n/g, "\\n");
|
|
195
213
|
return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
|
|
196
214
|
}
|
|
197
|
-
export function
|
|
198
|
-
const count = splitLines(content).length;
|
|
215
|
+
export function assertLineCount(count: number, displayPath: string, limit: number): void {
|
|
199
216
|
if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
|
|
200
217
|
}
|
|
218
|
+
export function assertLineLimit(content: string, displayPath: string, limit: number): void {
|
|
219
|
+
assertLineCount(splitLines(content).length, displayPath, limit);
|
|
220
|
+
}
|
|
201
221
|
export function assertByteLimit(content: string, displayPath: string, limit = MAX_BYTES): void {
|
|
202
222
|
if (Buffer.byteLength(content, "utf-8") > limit) {
|
|
203
223
|
throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${displayPath} (exceeds the ${limit / (1024 * 1024)}MB size limit). For very large files, use write.`);
|