pi-hashline-edit-pro 0.18.0 → 0.18.2
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 +5 -3
- package/index.ts +14 -3
- package/package.json +4 -1
- package/prompts/read-guidelines.md +2 -1
- package/prompts/read.md +1 -3
- package/prompts/replace-guidelines.md +2 -2
- package/prompts/undo-last-replace-guidelines.md +1 -2
- package/src/constants.ts +1 -1
- package/src/file-kind.ts +1 -1
- package/src/hash-store.ts +20 -6
- package/src/hashline/apply.ts +32 -22
- package/src/hashline/hash.ts +117 -43
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +3 -1
- package/src/read.ts +1 -6
- package/src/replace-undo.ts +2 -2
- package/src/replace.ts +71 -52
- package/src/utils.ts +6 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
|
|
|
13
13
|
This fork makes two changes that compound:
|
|
14
14
|
|
|
15
15
|
1. **3-character hash length** over a 64-char URL-safe base64 alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 262,144 buckets.
|
|
16
|
-
2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is
|
|
16
|
+
2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (32KB, 262,144 bits) using a hint cursor for O(1) amortized lookup. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -142,7 +142,9 @@ The file is created automatically when any setting is toggled. Both fields are i
|
|
|
142
142
|
|
|
143
143
|
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
144
144
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
145
|
-
- **Strict patch content.** If `content_lines` contains `+HASH│`
|
|
145
|
+
- **Strict patch content.** If `content_lines` contains diff-preview rows — `+HASH│` addition prefixes, `-HASH│` or `- │` deletion rows (the padded format the diff preview emits), or `-N ` numbered deletion rows — the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
|
|
146
|
+
|
|
147
|
+
- **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
|
|
146
148
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
147
149
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
148
150
|
- **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
@@ -156,7 +158,7 @@ The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squin
|
|
|
156
158
|
|
|
157
159
|
Before hashing, each line is normalized: carriage returns are stripped and trailing whitespace is trimmed. This `canon()` normalization prevents insignificant whitespace changes from cascade-triggering hash churn across the file. Two lines that differ only in trailing spaces or `\r` characters produce the same hash, so anchor stability is preserved across editor-save cycles that add or remove trailing whitespace.
|
|
158
160
|
|
|
159
|
-
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is
|
|
161
|
+
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (32KB, 262,144 bits) using a hint cursor for O(1) amortized lookup. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
|
|
160
162
|
The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
|
|
161
163
|
`HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
|
|
162
164
|
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { initHasher } from "./src/hashline";
|
|
3
3
|
import { regReplace, regReplaceFlat } from "./src/replace";
|
|
4
|
-
import { regReplaceUndo } from "./src/replace-undo";
|
|
4
|
+
import { regReplaceUndo, clearUndo } from "./src/replace-undo";
|
|
5
5
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
6
6
|
import { visLines } from "./src/utils";
|
|
7
7
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
} from "./src/config";
|
|
13
13
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
14
14
|
import { readNormFile } from "./src/file-reader";
|
|
15
|
-
|
|
15
|
+
import { toCwd } from "./src/paths";
|
|
16
|
+
import { resolveTarget } from "./src/fs-write";
|
|
16
17
|
function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
|
|
17
18
|
if (mode === "flat") {
|
|
18
19
|
regReplaceFlat(pi, autoRead);
|
|
@@ -73,8 +74,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
pi.on("tool_result", async (event, ctx) => {
|
|
76
|
-
if (!autoRead) return;
|
|
77
77
|
if (event.isError) return;
|
|
78
|
+
if (event.toolName === "write") {
|
|
79
|
+
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
80
|
+
if (typeof writtenPath === "string") {
|
|
81
|
+
try {
|
|
82
|
+
clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("Failed to clear undo after write:", error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!autoRead) return;
|
|
78
89
|
if (event.toolName !== "write" && event.toolName !== "replace") return;
|
|
79
90
|
|
|
80
91
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
41
41
|
"@earendil-works/pi-tui": "*"
|
|
42
42
|
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.13.0"
|
|
45
|
+
},
|
|
43
46
|
"scripts": {
|
|
44
47
|
"test": "vitest run",
|
|
45
48
|
"test:watch": "vitest",
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
1
|
+
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
2
|
+
- `read`: call again after any edit to that file — changed lines get new anchors.
|
package/prompts/read.md
CHANGED
|
@@ -1,3 +1 @@
|
|
|
1
|
-
Read a text file
|
|
2
|
-
|
|
3
|
-
Text → HASH│content lines. Images → visual attachments. Binary/directory → rejected. Empty → HASH│ (replace to insert). Pageable with offset/limit. BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
1
|
+
Read a text file; each line returned as HASH│content with a 3-char URL-safe base64 hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
- `replace`:
|
|
2
|
-
- `replace`:
|
|
1
|
+
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
|
|
2
|
+
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
- `undo_last_replace`:
|
|
2
|
-
- `undo_last_replace`: only the most recent replace per file is tracked.
|
|
1
|
+
- `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
|
package/src/constants.ts
CHANGED
|
@@ -6,7 +6,7 @@ export const MAX_HASH_LINES = 1_000_000;
|
|
|
6
6
|
export const MAX_HASH_RETRIES = 262_144;
|
|
7
7
|
|
|
8
8
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
9
|
-
export const HASH_STORE_VERSION =
|
|
9
|
+
export const HASH_STORE_VERSION = 3;
|
|
10
10
|
export const CONTENT_LINES_NOT_STRING_MSG =
|
|
11
11
|
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
12
12
|
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
|
package/src/file-kind.ts
CHANGED
|
@@ -81,7 +81,7 @@ export async function loadFileKindAndText(
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
84
|
+
const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
85
85
|
let hadUtf8DecodeErrors = false;
|
|
86
86
|
const parts: string[] = [];
|
|
87
87
|
|
package/src/hash-store.ts
CHANGED
|
@@ -2,10 +2,9 @@ import { existsSync } from "fs";
|
|
|
2
2
|
import { readFile, rename, mkdir, stat } from "fs/promises";
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
5
|
-
import { errCode } from "./utils";
|
|
5
|
+
import { errCode, splitLines } from "./utils";
|
|
6
6
|
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
7
7
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
|
-
|
|
9
8
|
type SqlParams = (string | number)[];
|
|
10
9
|
|
|
11
10
|
interface Prepared {
|
|
@@ -37,7 +36,7 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
|
|
40
|
-
|
|
39
|
+
let exitHandlerRegistered = false;
|
|
41
40
|
function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
42
41
|
const db = new DatabaseSync(storePath, {
|
|
43
42
|
timeout: HASH_STORE_BUSY_TIMEOUT,
|
|
@@ -92,11 +91,27 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
cachedDb = { path: storePath, db, stmts };
|
|
94
|
+
|
|
95
|
+
if (!exitHandlerRegistered) {
|
|
96
|
+
exitHandlerRegistered = true;
|
|
97
|
+
process.once("exit", () => shutdownHashStore());
|
|
98
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
99
|
+
process.once(sig, () => {
|
|
100
|
+
shutdownHashStore();
|
|
101
|
+
process.kill(process.pid, sig);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
return { stmts, engine: "node:sqlite" };
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
export function shutdownHashStore(): void {
|
|
99
110
|
if (cachedDb) {
|
|
111
|
+
try {
|
|
112
|
+
cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
100
115
|
cachedDb.db.close();
|
|
101
116
|
cachedDb = null;
|
|
102
117
|
}
|
|
@@ -145,12 +160,11 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
|
145
160
|
rows.push([
|
|
146
161
|
key,
|
|
147
162
|
contentChecksum(value.content),
|
|
148
|
-
value.content
|
|
163
|
+
splitLines(value.content).length,
|
|
149
164
|
JSON.stringify(value.hashes),
|
|
150
165
|
Date.now(),
|
|
151
166
|
]);
|
|
152
167
|
}
|
|
153
|
-
|
|
154
168
|
if (rows.length > 0) {
|
|
155
169
|
db.exec("BEGIN IMMEDIATE");
|
|
156
170
|
try {
|
|
@@ -178,7 +192,7 @@ export function getSnapshot(
|
|
|
178
192
|
content: string,
|
|
179
193
|
): string[] | undefined {
|
|
180
194
|
const checksum = contentChecksum(content);
|
|
181
|
-
const lineCount = content
|
|
195
|
+
const lineCount = splitLines(content).length;
|
|
182
196
|
const row = store.stmts.get(path, checksum, lineCount);
|
|
183
197
|
return row ? (JSON.parse(row.hashes as string) as string[]) : undefined;
|
|
184
198
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf,
|
|
1
|
+
import { abortIf, splitLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
|
|
2
2
|
import { _lineHashesPure, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdits,
|
|
@@ -18,22 +18,22 @@ type LIdx = {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
export function buildIdx(content: string): LIdx {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
const fileLines = splitLines(content);
|
|
22
|
+
const lineStarts: number[] = [];
|
|
23
|
+
let offset = 0;
|
|
24
|
+
|
|
25
|
+
for (let index = 0; index < fileLines.length; index++) {
|
|
26
|
+
lineStarts.push(offset);
|
|
27
|
+
offset += fileLines[index]!.length;
|
|
28
|
+
if (index < fileLines.length - 1) {
|
|
29
|
+
offset += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
return {
|
|
34
|
+
fileLines,
|
|
35
|
+
lineStarts,
|
|
36
|
+
};
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
type RESpan = {
|
|
@@ -124,16 +124,26 @@ function resToSpan(
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
if (content.endsWith("\n")) {
|
|
128
|
+
return {
|
|
129
|
+
kind: "replace",
|
|
130
|
+
index,
|
|
131
|
+
label,
|
|
132
|
+
start: lineStarts[startLine - 1]!,
|
|
133
|
+
end: content.length,
|
|
134
|
+
replacement: "",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
return {
|
|
128
139
|
kind: "replace",
|
|
129
140
|
index,
|
|
130
141
|
label,
|
|
131
142
|
start: Math.max(0, lineStarts[startLine - 1]! - 1),
|
|
132
|
-
end:
|
|
143
|
+
end: content.length,
|
|
133
144
|
replacement: "",
|
|
134
145
|
};
|
|
135
146
|
}
|
|
136
|
-
|
|
137
147
|
function assertNoConflict(spans: RESpan[]): void {
|
|
138
148
|
for (let leftIndex = 0; leftIndex < spans.length; leftIndex++) {
|
|
139
149
|
const left = spans[leftIndex]!;
|
|
@@ -375,14 +385,14 @@ export function changedRange(
|
|
|
375
385
|
if (original.length === 0) {
|
|
376
386
|
return {
|
|
377
387
|
firstChangedLine: 1,
|
|
378
|
-
lastChangedLine:
|
|
388
|
+
lastChangedLine: splitLines(result).length,
|
|
379
389
|
};
|
|
380
390
|
}
|
|
381
391
|
|
|
382
392
|
if (result.startsWith(original) && original.endsWith("\n")) {
|
|
383
393
|
return {
|
|
384
|
-
firstChangedLine:
|
|
385
|
-
lastChangedLine:
|
|
394
|
+
firstChangedLine: splitLines(original).length + 1,
|
|
395
|
+
lastChangedLine: splitLines(result).length,
|
|
386
396
|
};
|
|
387
397
|
}
|
|
388
398
|
|
|
@@ -415,7 +425,7 @@ export function changedRange(
|
|
|
415
425
|
const firstChangedLine = idxToLine(firstDiff + 1, result);
|
|
416
426
|
let lastChangedLine: number;
|
|
417
427
|
if (lastRes < firstDiff) {
|
|
418
|
-
lastChangedLine = result.length === 0 ? 1 :
|
|
428
|
+
lastChangedLine = result.length === 0 ? 1 : splitLines(result).length;
|
|
419
429
|
} else if (
|
|
420
430
|
firstDiff === 0 &&
|
|
421
431
|
original.length > 0 &&
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { splitLines } from "../utils";
|
|
2
2
|
import {
|
|
3
3
|
loadHashStore,
|
|
4
4
|
type HashStore,
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
upsertSnapshot,
|
|
7
7
|
} from "../hash-store";
|
|
8
8
|
import { xxh32, contentChecksum, initHasher } from "./hasher";
|
|
9
|
-
|
|
10
9
|
export { initHasher };
|
|
11
10
|
|
|
12
11
|
export const HASH_LEN = 3;
|
|
@@ -22,58 +21,111 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
|
|
|
22
21
|
const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
|
|
23
22
|
export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
24
23
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
out +=
|
|
32
|
-
ALPH[
|
|
33
|
-
(n >>> ((HASH_LEN - 1 - j) * ALPH_BITS)) &
|
|
34
|
-
ALPH_MASK
|
|
35
|
-
]!;
|
|
36
|
-
}
|
|
37
|
-
return out;
|
|
24
|
+
function idxToHash(idx: number): string {
|
|
25
|
+
let out = "";
|
|
26
|
+
for (let j = 0; j < HASH_LEN; j++) {
|
|
27
|
+
out += ALPH[(idx >>> ((HASH_LEN - 1 - j) * ALPH_BITS)) & ALPH_MASK]!;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
38
30
|
}
|
|
39
31
|
|
|
32
|
+
const HASH_TABLE: string[] = Array.from(
|
|
33
|
+
{ length: 262_144 },
|
|
34
|
+
(_, i) => idxToHash(i),
|
|
35
|
+
);
|
|
36
|
+
|
|
40
37
|
export const HL_PREFIX_RE = new RegExp(
|
|
41
38
|
`^\\s*(?:>>>|>>)?\\s*${HASH_CLASS}│`,
|
|
42
39
|
);
|
|
43
40
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
44
41
|
`^\\+\\s*${HASH_CLASS}│`,
|
|
45
42
|
);
|
|
43
|
+
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
44
|
+
`^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
|
|
45
|
+
);
|
|
46
46
|
export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
47
47
|
|
|
48
48
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
49
49
|
|
|
50
|
-
|
|
51
50
|
function canon(line: string): string {
|
|
52
51
|
return line.replace(/\r/g, "").trimEnd();
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
const BITSET_WORDS = 8192;
|
|
55
|
+
|
|
56
|
+
function getBit(bits: Uint32Array, idx: number): boolean {
|
|
57
|
+
return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setBit(bits: Uint32Array, idx: number): void {
|
|
61
|
+
bits[idx >>> 5] |= 1 << (idx & 31);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
65
|
+
const totalWords = bits.length;
|
|
66
|
+
const totalBits = totalWords * 32;
|
|
67
|
+
|
|
68
|
+
if (start >= totalBits) start = 0;
|
|
69
|
+
|
|
70
|
+
const wordIdx = start >>> 5;
|
|
71
|
+
const bitOffset = start & 31;
|
|
72
|
+
|
|
73
|
+
let word = bits[wordIdx];
|
|
74
|
+
for (let b = bitOffset; b < 32; b++) {
|
|
75
|
+
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (let w = wordIdx + 1; w < totalWords; w++) {
|
|
79
|
+
word = bits[w];
|
|
80
|
+
if (~word !== 0) {
|
|
81
|
+
for (let b = 0; b < 32; b++) {
|
|
82
|
+
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (let w = 0; w < wordIdx; w++) {
|
|
88
|
+
word = bits[w];
|
|
89
|
+
if (~word !== 0) {
|
|
90
|
+
for (let b = 0; b < 32; b++) {
|
|
91
|
+
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
word = bits[wordIdx];
|
|
97
|
+
for (let b = 0; b < bitOffset; b++) {
|
|
98
|
+
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error("Hash space exhausted");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
|
|
105
|
+
if (!getBit(used, baseIdx)) {
|
|
106
|
+
setBit(used, baseIdx);
|
|
107
|
+
hint.value = baseIdx + 1;
|
|
108
|
+
return HASH_TABLE[baseIdx];
|
|
109
|
+
}
|
|
110
|
+
const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
|
|
111
|
+
const nextIdx = nextZeroBit(used, start);
|
|
112
|
+
setBit(used, nextIdx);
|
|
113
|
+
hint.value = nextIdx + 1;
|
|
114
|
+
return HASH_TABLE[nextIdx];
|
|
65
115
|
}
|
|
66
116
|
|
|
67
117
|
export function _lineHashesPure(content: string): string[] {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
118
|
+
const lines = splitLines(content);
|
|
119
|
+
const hashes = new Array<string>(lines.length);
|
|
120
|
+
const used = new Uint32Array(BITSET_WORDS);
|
|
121
|
+
const hint = { value: 0 };
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
const c = canon(lines[i]!);
|
|
125
|
+
const baseIdx = xxh32(c) >>> 14;
|
|
126
|
+
hashes[i] = assignHash(used, baseIdx, hint);
|
|
127
|
+
}
|
|
128
|
+
return hashes;
|
|
77
129
|
}
|
|
78
130
|
|
|
79
131
|
export async function lineHashes(
|
|
@@ -96,7 +148,7 @@ export async function lineHashes(
|
|
|
96
148
|
previous.removedHashes,
|
|
97
149
|
);
|
|
98
150
|
if (persist !== false) {
|
|
99
|
-
upsertSnapshot(hashStore, path, contentChecksum(content), content
|
|
151
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
|
|
100
152
|
}
|
|
101
153
|
return newHashes;
|
|
102
154
|
}
|
|
@@ -108,23 +160,41 @@ export async function lineHashes(
|
|
|
108
160
|
|
|
109
161
|
const newHashes = _lineHashesPure(content);
|
|
110
162
|
if (persist !== false) {
|
|
111
|
-
upsertSnapshot(hashStore, path, contentChecksum(content), content
|
|
163
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
|
|
112
164
|
}
|
|
113
165
|
return newHashes;
|
|
114
166
|
}
|
|
115
167
|
|
|
168
|
+
function hashToIndex(hash: string): number {
|
|
169
|
+
let idx = 0;
|
|
170
|
+
for (let j = 0; j < HASH_LEN; j++) {
|
|
171
|
+
const charIdx = ALPH.indexOf(hash[j]!);
|
|
172
|
+
if (charIdx < 0) return -1;
|
|
173
|
+
idx = (idx << ALPH_BITS) | charIdx;
|
|
174
|
+
}
|
|
175
|
+
return idx;
|
|
176
|
+
}
|
|
177
|
+
|
|
116
178
|
function mapStableHashes(
|
|
117
179
|
oldContent: string,
|
|
118
180
|
oldHashes: string[],
|
|
119
181
|
newContent: string,
|
|
120
182
|
removedHashes?: Set<string>,
|
|
121
183
|
): string[] {
|
|
122
|
-
const newLines = newContent
|
|
184
|
+
const newLines = splitLines(newContent);
|
|
123
185
|
const newHashes = new Array<string>(newLines.length);
|
|
124
|
-
const used = new
|
|
186
|
+
const used = new Uint32Array(BITSET_WORDS);
|
|
187
|
+
const hint = { value: 0 };
|
|
188
|
+
|
|
189
|
+
if (removedHashes) {
|
|
190
|
+
for (const hash of removedHashes) {
|
|
191
|
+
const idx = hashToIndex(hash);
|
|
192
|
+
if (idx >= 0) setBit(used, idx);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
125
195
|
|
|
126
196
|
const contentMap = new Map<string, { index: number; hash: string }[]>();
|
|
127
|
-
const oldLines = oldContent
|
|
197
|
+
const oldLines = splitLines(oldContent);
|
|
128
198
|
for (let i = 0; i < oldLines.length; i++) {
|
|
129
199
|
const line = oldLines[i]!;
|
|
130
200
|
const entry = { index: i, hash: oldHashes[i]! };
|
|
@@ -155,14 +225,18 @@ function mapStableHashes(
|
|
|
155
225
|
if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
|
|
156
226
|
const match = candidates.splice(bestIdx, 1)[0]!;
|
|
157
227
|
newHashes[i] = match.hash;
|
|
158
|
-
|
|
228
|
+
const matchIdx = hashToIndex(match.hash);
|
|
229
|
+
if (matchIdx >= 0) {
|
|
230
|
+
setBit(used, matchIdx);
|
|
231
|
+
if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
|
|
232
|
+
}
|
|
159
233
|
}
|
|
160
234
|
|
|
161
235
|
for (let i = 0; i < newLines.length; i++) {
|
|
162
236
|
if (newHashes[i]) continue;
|
|
163
237
|
const c = canon(newLines[i]!);
|
|
164
|
-
const
|
|
165
|
-
newHashes[i] =
|
|
238
|
+
const baseIdx = xxh32(c) >>> 14;
|
|
239
|
+
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
166
240
|
}
|
|
167
241
|
return newHashes;
|
|
168
242
|
}
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
4
|
HL_PREFIX_PLUS_RE,
|
|
5
|
+
HL_PREFIX_MINUS_RE,
|
|
5
6
|
DIFF_MINUS_RE,
|
|
6
7
|
} from "./hash";
|
|
7
8
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
@@ -46,10 +47,11 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
46
47
|
if (!line.length) continue;
|
|
47
48
|
if (
|
|
48
49
|
HL_PREFIX_PLUS_RE.test(line) ||
|
|
50
|
+
HL_PREFIX_MINUS_RE.test(line) ||
|
|
49
51
|
DIFF_MINUS_RE.test(line)
|
|
50
52
|
) {
|
|
51
53
|
throw new Error(
|
|
52
|
-
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like
|
|
54
|
+
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
57
|
}
|
package/src/read.ts
CHANGED
|
@@ -2,8 +2,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import {
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
|
-
DEFAULT_MAX_BYTES,
|
|
6
|
-
DEFAULT_MAX_LINES,
|
|
7
5
|
truncateHead,
|
|
8
6
|
type TruncationResult,
|
|
9
7
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -18,10 +16,7 @@ import { visLines } from "./utils";
|
|
|
18
16
|
import { loadP, loadGuide } from "./prompts";
|
|
19
17
|
import { valAccess } from "./validation";
|
|
20
18
|
|
|
21
|
-
const R_DESC = loadP("../prompts/read.md"
|
|
22
|
-
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
|
|
23
|
-
DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
|
|
24
|
-
});
|
|
19
|
+
const R_DESC = loadP("../prompts/read.md");
|
|
25
20
|
|
|
26
21
|
const R_SNIPPET = loadP("../prompts/read-snippet.md");
|
|
27
22
|
const R_GUIDE = loadGuide("../prompts/read-guidelines.md");
|
package/src/replace-undo.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { contentChecksum } from "./hashline/hasher";
|
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
9
9
|
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
10
|
-
import { cntDiff } from "./utils";
|
|
10
|
+
import { cntDiff, splitLines } from "./utils";
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { buildMetrics } from "./replace-response";
|
|
13
13
|
export interface UndoEntry {
|
|
@@ -84,7 +84,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
84
84
|
);
|
|
85
85
|
|
|
86
86
|
const store = await loadHashStore();
|
|
87
|
-
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), undo.content
|
|
87
|
+
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
|
|
88
88
|
|
|
89
89
|
clearUndo(mutationTargetPath);
|
|
90
90
|
|
package/src/replace.ts
CHANGED
|
@@ -68,7 +68,7 @@ const changeItemSchema = Type.Object(
|
|
|
68
68
|
|
|
69
69
|
export const editToolSchema = Type.Object(
|
|
70
70
|
{
|
|
71
|
-
changes: Type.Array(changeItemSchema, { description: "Array of edits
|
|
71
|
+
changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
|
|
72
72
|
path: Type.String({ description: "Path to edit" }),
|
|
73
73
|
},
|
|
74
74
|
{ additionalProperties: false },
|
|
@@ -117,6 +117,19 @@ interface PipelineResult {
|
|
|
117
117
|
|
|
118
118
|
const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
|
|
119
119
|
|
|
120
|
+
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
|
|
121
|
+
|
|
122
|
+
export function assertNoLegacyKeys(request: unknown): void {
|
|
123
|
+
if (!isRec(request)) return;
|
|
124
|
+
for (const legacyKey of LEGACY_KS) {
|
|
125
|
+
if (has(request, legacyKey)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
export function assertReq(
|
|
121
134
|
request: unknown,
|
|
122
135
|
flat?: boolean
|
|
@@ -125,13 +138,7 @@ export function assertReq(
|
|
|
125
138
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
|
|
129
|
-
if (has(request, legacyKey)) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
141
|
+
assertNoLegacyKeys(request);
|
|
135
142
|
|
|
136
143
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
137
144
|
|
|
@@ -148,6 +155,7 @@ export function assertReq(
|
|
|
148
155
|
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
|
|
149
156
|
}
|
|
150
157
|
}
|
|
158
|
+
|
|
151
159
|
export interface ExecPipelineOptions {
|
|
152
160
|
accessMode?: number;
|
|
153
161
|
signal?: AbortSignal;
|
|
@@ -155,6 +163,46 @@ export interface ExecPipelineOptions {
|
|
|
155
163
|
noPersist?: boolean;
|
|
156
164
|
}
|
|
157
165
|
|
|
166
|
+
function collectRemovedHashes(
|
|
167
|
+
resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
|
|
168
|
+
originalHashes: string[],
|
|
169
|
+
): Set<string> {
|
|
170
|
+
const removedHashes = new Set<string>();
|
|
171
|
+
for (const edit of resolved) {
|
|
172
|
+
const startHash = edit.hash_range_inclusive[0].hash;
|
|
173
|
+
const endHash = edit.hash_range_inclusive[1].hash;
|
|
174
|
+
const startLine = originalHashes.indexOf(startHash);
|
|
175
|
+
const endLine = originalHashes.indexOf(endHash);
|
|
176
|
+
if (startLine >= 0 && endLine >= 0) {
|
|
177
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
178
|
+
removedHashes.add(originalHashes[i]!);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return removedHashes;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function countLineChanges(
|
|
186
|
+
resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }]; content_lines: string[] }[],
|
|
187
|
+
originalHashes: string[],
|
|
188
|
+
noopEdits: { editIndex: number }[] | undefined,
|
|
189
|
+
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
190
|
+
let totalAddedLines = 0;
|
|
191
|
+
let totalRemovedLines = 0;
|
|
192
|
+
const noopIndices = new Set(noopEdits?.map((n) => n.editIndex) ?? []);
|
|
193
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
194
|
+
if (noopIndices.has(i)) continue;
|
|
195
|
+
const edit = resolved[i]!;
|
|
196
|
+
const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
|
|
197
|
+
const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
|
|
198
|
+
if (startLine >= 0 && endLine >= 0) {
|
|
199
|
+
totalRemovedLines += endLine - startLine + 1;
|
|
200
|
+
}
|
|
201
|
+
totalAddedLines += edit.content_lines.length;
|
|
202
|
+
}
|
|
203
|
+
return { totalAddedLines, totalRemovedLines };
|
|
204
|
+
}
|
|
205
|
+
|
|
158
206
|
export async function execPipeline(
|
|
159
207
|
params: ReqParams,
|
|
160
208
|
cwd: string,
|
|
@@ -187,18 +235,7 @@ export async function execPipeline(
|
|
|
187
235
|
|
|
188
236
|
const result = anchorResult.content;
|
|
189
237
|
|
|
190
|
-
const removedHashes =
|
|
191
|
-
for (const edit of resolved) {
|
|
192
|
-
const startHash = edit.hash_range_inclusive[0].hash;
|
|
193
|
-
const endHash = edit.hash_range_inclusive[1].hash;
|
|
194
|
-
const startLine = originalHashes.indexOf(startHash);
|
|
195
|
-
const endLine = originalHashes.indexOf(endHash);
|
|
196
|
-
if (startLine >= 0 && endLine >= 0) {
|
|
197
|
-
for (let i = startLine; i <= endLine; i++) {
|
|
198
|
-
removedHashes.add(originalHashes[i]!);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
238
|
+
const removedHashes = collectRemovedHashes(resolved, originalHashes);
|
|
202
239
|
|
|
203
240
|
const noPersist = options?.noPersist;
|
|
204
241
|
const resultHashes = await lineHashes(result, absolutePath, {
|
|
@@ -209,19 +246,9 @@ export async function execPipeline(
|
|
|
209
246
|
|
|
210
247
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
211
248
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
for (let i = 0; i < resolved.length; i++) {
|
|
216
|
-
if (noopIndices.has(i)) continue;
|
|
217
|
-
const edit = resolved[i]!;
|
|
218
|
-
const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
|
|
219
|
-
const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
|
|
220
|
-
if (startLine >= 0 && endLine >= 0) {
|
|
221
|
-
totalRemovedLines += endLine - startLine + 1;
|
|
222
|
-
}
|
|
223
|
-
totalAddedLines += edit.content_lines.length;
|
|
224
|
-
}
|
|
249
|
+
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
250
|
+
resolved, originalHashes, anchorResult.noopEdits,
|
|
251
|
+
);
|
|
225
252
|
|
|
226
253
|
return {
|
|
227
254
|
path,
|
|
@@ -249,6 +276,11 @@ export async function compPreview(
|
|
|
249
276
|
): Promise<RPreview> {
|
|
250
277
|
try {
|
|
251
278
|
const normalized = normReq(request);
|
|
279
|
+
if (flat && isRec(request) && Array.isArray(request.changes)) {
|
|
280
|
+
return {
|
|
281
|
+
error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send content_lines and hash_range_inclusive at the top level (one edit per call), or use bulk mode for multiple edits per call.`
|
|
282
|
+
};
|
|
283
|
+
}
|
|
252
284
|
assertReq(normalized, flat);
|
|
253
285
|
const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
|
|
254
286
|
normalized,
|
|
@@ -289,27 +321,12 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
289
321
|
m.setText(content);
|
|
290
322
|
return m;
|
|
291
323
|
}
|
|
324
|
+
|
|
292
325
|
const MODE_CFG = {
|
|
293
326
|
flat: {
|
|
294
|
-
desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
|
|
295
|
-
examples: [
|
|
296
|
-
"", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
|
|
297
|
-
].join("\n"),
|
|
298
|
-
rules: "",
|
|
299
|
-
requestStructure: [
|
|
300
|
-
"Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
|
|
301
|
-
].join("\n"),
|
|
302
327
|
prefix: "performing one edit per call",
|
|
303
328
|
},
|
|
304
329
|
bulk: {
|
|
305
|
-
desc: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.",
|
|
306
|
-
examples: [
|
|
307
|
-
"", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
|
|
308
|
-
].join("\n"),
|
|
309
|
-
rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
|
|
310
|
-
requestStructure: [
|
|
311
|
-
"Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
|
|
312
|
-
].join("\n"),
|
|
313
330
|
prefix: "batching all changes to a file in one call",
|
|
314
331
|
},
|
|
315
332
|
} as const;
|
|
@@ -334,6 +351,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
334
351
|
promptGuidelines: E_GUIDE,
|
|
335
352
|
prepareArguments: opts.flat
|
|
336
353
|
? (args: unknown) => {
|
|
354
|
+
assertNoLegacyKeys(args);
|
|
337
355
|
if (!isRec(args)) return args as any;
|
|
338
356
|
const record = { ...args };
|
|
339
357
|
normalizeFilePath(record);
|
|
@@ -342,8 +360,10 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
342
360
|
}
|
|
343
361
|
return record;
|
|
344
362
|
}
|
|
345
|
-
: (args: unknown) =>
|
|
346
|
-
|
|
363
|
+
: (args: unknown) => {
|
|
364
|
+
assertNoLegacyKeys(args);
|
|
365
|
+
return normReq(args) as ReqParams;
|
|
366
|
+
},
|
|
347
367
|
renderShell: "default",
|
|
348
368
|
renderCall(args, theme, context) {
|
|
349
369
|
const previewInput = getPreviewInput(args);
|
|
@@ -435,7 +455,6 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
435
455
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
436
456
|
const canonical = normReq(params);
|
|
437
457
|
|
|
438
|
-
|
|
439
458
|
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
440
459
|
const path = normalizedParams.path;
|
|
441
460
|
const absolutePath = toCwd(path, ctx.cwd);
|
package/src/utils.ts
CHANGED
|
@@ -6,6 +6,12 @@ export function has(record: Record<string, unknown>, key: string): boolean {
|
|
|
6
6
|
return Object.hasOwn(record, key);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export function splitLines(text: string): string[] {
|
|
10
|
+
if (text.length === 0) return [""];
|
|
11
|
+
const lines = text.split("\n");
|
|
12
|
+
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export function visLines(text: string): string[] {
|
|
10
16
|
if (text.length === 0) return [];
|
|
11
17
|
const lines = text.split("\n");
|