pi-hashline-edit-pro 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/prompts/undo-last-replace-guidelines.md +0 -2
- package/src/constants.ts +1 -1
- package/src/hash-store.ts +20 -6
- package/src/hashline/apply.ts +32 -22
- package/src/hashline/hash.ts +114 -43
- package/src/replace-undo.ts +2 -2
- package/src/replace.ts +46 -26
- 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
|
|
|
@@ -156,7 +156,7 @@ The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squin
|
|
|
156
156
|
|
|
157
157
|
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
158
|
|
|
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
|
|
159
|
+
**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
160
|
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
161
|
`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
162
|
|
package/package.json
CHANGED
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/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,21 +21,19 @@ 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
|
);
|
|
@@ -47,33 +44,85 @@ export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
|
47
44
|
|
|
48
45
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
49
46
|
|
|
50
|
-
|
|
51
47
|
function canon(line: string): string {
|
|
52
48
|
return line.replace(/\r/g, "").trimEnd();
|
|
53
49
|
}
|
|
54
50
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
51
|
+
const BITSET_WORDS = 8192;
|
|
52
|
+
|
|
53
|
+
function getBit(bits: Uint32Array, idx: number): boolean {
|
|
54
|
+
return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setBit(bits: Uint32Array, idx: number): void {
|
|
58
|
+
bits[idx >>> 5] |= 1 << (idx & 31);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
62
|
+
const totalWords = bits.length;
|
|
63
|
+
const totalBits = totalWords * 32;
|
|
64
|
+
|
|
65
|
+
if (start >= totalBits) start = 0;
|
|
66
|
+
|
|
67
|
+
const wordIdx = start >>> 5;
|
|
68
|
+
const bitOffset = start & 31;
|
|
69
|
+
|
|
70
|
+
let word = bits[wordIdx];
|
|
71
|
+
for (let b = bitOffset; b < 32; b++) {
|
|
72
|
+
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (let w = wordIdx + 1; w < totalWords; w++) {
|
|
76
|
+
word = bits[w];
|
|
77
|
+
if (~word !== 0) {
|
|
78
|
+
for (let b = 0; b < 32; b++) {
|
|
79
|
+
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let w = 0; w < wordIdx; w++) {
|
|
85
|
+
word = bits[w];
|
|
86
|
+
if (~word !== 0) {
|
|
87
|
+
for (let b = 0; b < 32; b++) {
|
|
88
|
+
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
word = bits[wordIdx];
|
|
94
|
+
for (let b = 0; b < bitOffset; b++) {
|
|
95
|
+
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error("Hash space exhausted");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
|
|
102
|
+
if (!getBit(used, baseIdx)) {
|
|
103
|
+
setBit(used, baseIdx);
|
|
104
|
+
hint.value = baseIdx + 1;
|
|
105
|
+
return HASH_TABLE[baseIdx];
|
|
106
|
+
}
|
|
107
|
+
const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
|
|
108
|
+
const nextIdx = nextZeroBit(used, start);
|
|
109
|
+
setBit(used, nextIdx);
|
|
110
|
+
hint.value = nextIdx + 1;
|
|
111
|
+
return HASH_TABLE[nextIdx];
|
|
65
112
|
}
|
|
66
113
|
|
|
67
114
|
export function _lineHashesPure(content: string): string[] {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
115
|
+
const lines = splitLines(content);
|
|
116
|
+
const hashes = new Array<string>(lines.length);
|
|
117
|
+
const used = new Uint32Array(BITSET_WORDS);
|
|
118
|
+
const hint = { value: 0 };
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const c = canon(lines[i]!);
|
|
122
|
+
const baseIdx = xxh32(c) >>> 14;
|
|
123
|
+
hashes[i] = assignHash(used, baseIdx, hint);
|
|
124
|
+
}
|
|
125
|
+
return hashes;
|
|
77
126
|
}
|
|
78
127
|
|
|
79
128
|
export async function lineHashes(
|
|
@@ -96,7 +145,7 @@ export async function lineHashes(
|
|
|
96
145
|
previous.removedHashes,
|
|
97
146
|
);
|
|
98
147
|
if (persist !== false) {
|
|
99
|
-
upsertSnapshot(hashStore, path, contentChecksum(content), content
|
|
148
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
|
|
100
149
|
}
|
|
101
150
|
return newHashes;
|
|
102
151
|
}
|
|
@@ -108,23 +157,41 @@ export async function lineHashes(
|
|
|
108
157
|
|
|
109
158
|
const newHashes = _lineHashesPure(content);
|
|
110
159
|
if (persist !== false) {
|
|
111
|
-
upsertSnapshot(hashStore, path, contentChecksum(content), content
|
|
160
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
|
|
112
161
|
}
|
|
113
162
|
return newHashes;
|
|
114
163
|
}
|
|
115
164
|
|
|
165
|
+
function hashToIndex(hash: string): number {
|
|
166
|
+
let idx = 0;
|
|
167
|
+
for (let j = 0; j < HASH_LEN; j++) {
|
|
168
|
+
const charIdx = ALPH.indexOf(hash[j]!);
|
|
169
|
+
if (charIdx < 0) return -1;
|
|
170
|
+
idx = (idx << ALPH_BITS) | charIdx;
|
|
171
|
+
}
|
|
172
|
+
return idx;
|
|
173
|
+
}
|
|
174
|
+
|
|
116
175
|
function mapStableHashes(
|
|
117
176
|
oldContent: string,
|
|
118
177
|
oldHashes: string[],
|
|
119
178
|
newContent: string,
|
|
120
179
|
removedHashes?: Set<string>,
|
|
121
180
|
): string[] {
|
|
122
|
-
const newLines = newContent
|
|
181
|
+
const newLines = splitLines(newContent);
|
|
123
182
|
const newHashes = new Array<string>(newLines.length);
|
|
124
|
-
const used = new
|
|
183
|
+
const used = new Uint32Array(BITSET_WORDS);
|
|
184
|
+
const hint = { value: 0 };
|
|
185
|
+
|
|
186
|
+
if (removedHashes) {
|
|
187
|
+
for (const hash of removedHashes) {
|
|
188
|
+
const idx = hashToIndex(hash);
|
|
189
|
+
if (idx >= 0) setBit(used, idx);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
125
192
|
|
|
126
193
|
const contentMap = new Map<string, { index: number; hash: string }[]>();
|
|
127
|
-
const oldLines = oldContent
|
|
194
|
+
const oldLines = splitLines(oldContent);
|
|
128
195
|
for (let i = 0; i < oldLines.length; i++) {
|
|
129
196
|
const line = oldLines[i]!;
|
|
130
197
|
const entry = { index: i, hash: oldHashes[i]! };
|
|
@@ -155,14 +222,18 @@ function mapStableHashes(
|
|
|
155
222
|
if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
|
|
156
223
|
const match = candidates.splice(bestIdx, 1)[0]!;
|
|
157
224
|
newHashes[i] = match.hash;
|
|
158
|
-
|
|
225
|
+
const matchIdx = hashToIndex(match.hash);
|
|
226
|
+
if (matchIdx >= 0) {
|
|
227
|
+
setBit(used, matchIdx);
|
|
228
|
+
if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
|
|
229
|
+
}
|
|
159
230
|
}
|
|
160
231
|
|
|
161
232
|
for (let i = 0; i < newLines.length; i++) {
|
|
162
233
|
if (newHashes[i]) continue;
|
|
163
234
|
const c = canon(newLines[i]!);
|
|
164
|
-
const
|
|
165
|
-
newHashes[i] =
|
|
235
|
+
const baseIdx = xxh32(c) >>> 14;
|
|
236
|
+
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
166
237
|
}
|
|
167
238
|
return newHashes;
|
|
168
239
|
}
|
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
|
@@ -148,6 +148,7 @@ export function assertReq(
|
|
|
148
148
|
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
|
|
151
152
|
export interface ExecPipelineOptions {
|
|
152
153
|
accessMode?: number;
|
|
153
154
|
signal?: AbortSignal;
|
|
@@ -155,6 +156,46 @@ export interface ExecPipelineOptions {
|
|
|
155
156
|
noPersist?: boolean;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
function collectRemovedHashes(
|
|
160
|
+
resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
|
|
161
|
+
originalHashes: string[],
|
|
162
|
+
): Set<string> {
|
|
163
|
+
const removedHashes = new Set<string>();
|
|
164
|
+
for (const edit of resolved) {
|
|
165
|
+
const startHash = edit.hash_range_inclusive[0].hash;
|
|
166
|
+
const endHash = edit.hash_range_inclusive[1].hash;
|
|
167
|
+
const startLine = originalHashes.indexOf(startHash);
|
|
168
|
+
const endLine = originalHashes.indexOf(endHash);
|
|
169
|
+
if (startLine >= 0 && endLine >= 0) {
|
|
170
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
171
|
+
removedHashes.add(originalHashes[i]!);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return removedHashes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function countLineChanges(
|
|
179
|
+
resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }]; content_lines: string[] }[],
|
|
180
|
+
originalHashes: string[],
|
|
181
|
+
noopEdits: { editIndex: number }[] | undefined,
|
|
182
|
+
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
183
|
+
let totalAddedLines = 0;
|
|
184
|
+
let totalRemovedLines = 0;
|
|
185
|
+
const noopIndices = new Set(noopEdits?.map((n) => n.editIndex) ?? []);
|
|
186
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
187
|
+
if (noopIndices.has(i)) continue;
|
|
188
|
+
const edit = resolved[i]!;
|
|
189
|
+
const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
|
|
190
|
+
const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
|
|
191
|
+
if (startLine >= 0 && endLine >= 0) {
|
|
192
|
+
totalRemovedLines += endLine - startLine + 1;
|
|
193
|
+
}
|
|
194
|
+
totalAddedLines += edit.content_lines.length;
|
|
195
|
+
}
|
|
196
|
+
return { totalAddedLines, totalRemovedLines };
|
|
197
|
+
}
|
|
198
|
+
|
|
158
199
|
export async function execPipeline(
|
|
159
200
|
params: ReqParams,
|
|
160
201
|
cwd: string,
|
|
@@ -187,18 +228,7 @@ export async function execPipeline(
|
|
|
187
228
|
|
|
188
229
|
const result = anchorResult.content;
|
|
189
230
|
|
|
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
|
-
}
|
|
231
|
+
const removedHashes = collectRemovedHashes(resolved, originalHashes);
|
|
202
232
|
|
|
203
233
|
const noPersist = options?.noPersist;
|
|
204
234
|
const resultHashes = await lineHashes(result, absolutePath, {
|
|
@@ -209,19 +239,9 @@ export async function execPipeline(
|
|
|
209
239
|
|
|
210
240
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
211
241
|
|
|
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
|
-
}
|
|
242
|
+
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
243
|
+
resolved, originalHashes, anchorResult.noopEdits,
|
|
244
|
+
);
|
|
225
245
|
|
|
226
246
|
return {
|
|
227
247
|
path,
|
|
@@ -289,6 +309,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
289
309
|
m.setText(content);
|
|
290
310
|
return m;
|
|
291
311
|
}
|
|
312
|
+
|
|
292
313
|
const MODE_CFG = {
|
|
293
314
|
flat: {
|
|
294
315
|
desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
|
|
@@ -435,7 +456,6 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
435
456
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
436
457
|
const canonical = normReq(params);
|
|
437
458
|
|
|
438
|
-
|
|
439
459
|
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
440
460
|
const path = normalizedParams.path;
|
|
441
461
|
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");
|