pi-hashline-edit-pro 0.18.2 → 0.18.3
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 +20 -2
- package/index.ts +4 -2
- package/package.json +1 -1
- package/src/config.ts +4 -4
- package/src/constants.ts +0 -3
- package/src/fs-write.ts +44 -2
- package/src/hash-store.ts +49 -10
- package/src/hashline/hash.ts +51 -15
- package/src/hashline/index.ts +2 -0
- package/src/read.ts +2 -2
- package/src/replace.ts +17 -12
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
94
94
|
|
|
95
95
|
### Stable hashing across edits
|
|
96
96
|
|
|
97
|
-
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
|
|
97
|
+
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions. A replace that produces identical content (a no-op, reported as "No changes made") never rotates hashes: no file change means no anchor change, so previously read anchors remain valid after a no-op.
|
|
98
98
|
|
|
99
99
|
The store is a SQLite database (WAL journal mode) keyed by canonical file path. Each snapshot stores a 64-bit content checksum (`xxhash64`) plus the per-line hashes, not the full text, so a cache hit is a single keyed lookup and a one-row write. Reads, replaces, undo, and pruning all share one transactional store, so concurrent Pi sessions editing different files never silently clobber each other's snapshots (per-path writers serialize via `BEGIN IMMEDIATE`; same-path concurrent edits still fail safe — stale anchors are rejected by content matching). Stale snapshots (for files that no longer exist) are pruned on session start.
|
|
100
100
|
|
|
@@ -138,6 +138,22 @@ Settings are stored in `~/.config/pi-hashline-edit-pro/config.json`:
|
|
|
138
138
|
|
|
139
139
|
The file is created automatically when any setting is toggled. Both fields are independent — toggling one never clobbers the other.
|
|
140
140
|
|
|
141
|
+
### Error codes
|
|
142
|
+
|
|
143
|
+
| Code | Meaning |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields. |
|
|
146
|
+
| `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
|
|
147
|
+
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
148
|
+
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
149
|
+
| `[E_INVALID_PATCH]` | `content_lines` contains diff-preview rows (`+HASH│`, `-HASH│`, `- │`, `-N `). |
|
|
150
|
+
| `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
|
|
151
|
+
| `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
|
|
152
|
+
| `[E_BAD_OP]` | Range start line is after range end line. |
|
|
153
|
+
| `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
|
|
154
|
+
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
155
|
+
| `[E_FILE_TOO_LARGE]` | The file exceeds the 262,144-line hashline limit. |
|
|
156
|
+
|
|
141
157
|
## Design Decisions
|
|
142
158
|
|
|
143
159
|
- **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.
|
|
@@ -149,7 +165,7 @@ The file is created automatically when any setting is toggled. Both fields are i
|
|
|
149
165
|
- **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.
|
|
150
166
|
- **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.
|
|
151
167
|
- **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
|
|
152
|
-
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history.
|
|
168
|
+
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history. If the database is corrupt or unreadable it is quarantined (renamed to `hash-store.sqlite.corrupt-<timestamp>`) and rebuilt from content on the next session start — the store is a cache, never a source of truth.
|
|
153
169
|
## Hashing
|
|
154
170
|
|
|
155
171
|
Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
|
|
@@ -162,6 +178,8 @@ Before hashing, each line is normalized: carriage returns are stripped and trail
|
|
|
162
178
|
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).
|
|
163
179
|
`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.
|
|
164
180
|
|
|
181
|
+
The 3-character space holds 262,144 unique anchors, so files are capped at 262,144 lines: `read` and `replace` reject larger files with `[E_FILE_TOO_LARGE]` (use `write` or a non-line-based approach for very large files).
|
|
182
|
+
|
|
165
183
|
### Bare-prefix detector
|
|
166
184
|
|
|
167
185
|
With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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";
|
|
8
|
+
import { MAX_HASH_LINES } from "./src/hashline";
|
|
8
9
|
import {
|
|
9
10
|
readConfig,
|
|
10
11
|
toggleReplaceMode,
|
|
@@ -92,8 +93,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
92
93
|
if (typeof filePath !== "string") return;
|
|
93
94
|
|
|
94
95
|
try {
|
|
95
|
-
const { normalized, fileHashes, absolutePath } = await readNormFile(
|
|
96
|
-
|
|
96
|
+
const { normalized, fileHashes, absolutePath } = await readNormFile(
|
|
97
|
+
filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
|
|
98
|
+
);
|
|
97
99
|
if (visLines(normalized).length === 0) return;
|
|
98
100
|
|
|
99
101
|
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFile
|
|
2
|
-
import {
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { configPath } from "./paths";
|
|
3
3
|
import { errCode } from "./utils";
|
|
4
|
+
import { writeAtomic } from "./fs-write";
|
|
4
5
|
|
|
5
6
|
export type ReplaceMode = "bulk" | "flat";
|
|
6
7
|
export interface Config {
|
|
@@ -32,8 +33,7 @@ export async function readConfig(): Promise<Config> {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
export async function writeConfig(config: Config): Promise<void> {
|
|
35
|
-
await
|
|
36
|
-
await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
36
|
+
await writeAtomic(configPath(), JSON.stringify(config, null, 2));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
|
package/src/constants.ts
CHANGED
|
@@ -2,9 +2,6 @@ export const AUTO_READ_MAX = 2000;
|
|
|
2
2
|
export const SNIFF_BYTES = 8192;
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
|
|
5
|
-
export const MAX_HASH_LINES = 1_000_000;
|
|
6
|
-
export const MAX_HASH_RETRIES = 262_144;
|
|
7
|
-
|
|
8
5
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
9
6
|
export const HASH_STORE_VERSION = 3;
|
|
10
7
|
export const CONTENT_LINES_NOT_STRING_MSG =
|
package/src/fs-write.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
lstat,
|
|
4
4
|
mkdir,
|
|
5
5
|
open,
|
|
6
|
+
readdir,
|
|
6
7
|
readlink,
|
|
7
8
|
rename,
|
|
8
9
|
rm,
|
|
@@ -71,6 +72,44 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
71
72
|
return resParts(root, parts);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
const TEMP_PREFIX = ".tmp-";
|
|
76
|
+
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
77
|
+
const sweptDirs = new Set<string>();
|
|
78
|
+
|
|
79
|
+
async function sweepStaleTemps(dir: string): Promise<void> {
|
|
80
|
+
if (sweptDirs.has(dir)) return;
|
|
81
|
+
sweptDirs.add(dir);
|
|
82
|
+
try {
|
|
83
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.isFile() || !entry.name.startsWith(TEMP_PREFIX)) continue;
|
|
87
|
+
const tempPath = join(dir, entry.name);
|
|
88
|
+
try {
|
|
89
|
+
const stats = await stat(tempPath);
|
|
90
|
+
if (now - stats.mtimeMs > STALE_TEMP_MS) {
|
|
91
|
+
await rm(tempPath, { force: true });
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function syncDir(dir: string): Promise<void> {
|
|
101
|
+
if (process.platform === "win32") return;
|
|
102
|
+
try {
|
|
103
|
+
const handle = await open(dir, "r");
|
|
104
|
+
try {
|
|
105
|
+
await handle.sync();
|
|
106
|
+
} finally {
|
|
107
|
+
await handle.close();
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
export async function writeAtomic(
|
|
75
114
|
path: string,
|
|
76
115
|
content: string,
|
|
@@ -92,7 +131,8 @@ export async function writeAtomic(
|
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
const dir = dirname(targetPath);
|
|
95
|
-
|
|
134
|
+
await sweepStaleTemps(dir);
|
|
135
|
+
const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`);
|
|
96
136
|
await mkdir(dir, { recursive: true });
|
|
97
137
|
const tempHandle = await open(tempPath, "wx", 0o600);
|
|
98
138
|
try {
|
|
@@ -100,6 +140,7 @@ export async function writeAtomic(
|
|
|
100
140
|
if (existingStats) {
|
|
101
141
|
await tempHandle.chmod(existingStats.mode & 0o7777);
|
|
102
142
|
}
|
|
143
|
+
await tempHandle.sync();
|
|
103
144
|
} catch (error: unknown) {
|
|
104
145
|
await tempHandle.close();
|
|
105
146
|
try { await rm(tempPath, { force: true }); } catch {}
|
|
@@ -108,12 +149,13 @@ export async function writeAtomic(
|
|
|
108
149
|
try {
|
|
109
150
|
await tempHandle.close();
|
|
110
151
|
await rename(tempPath, targetPath);
|
|
152
|
+
await syncDir(dir);
|
|
111
153
|
} catch (error: unknown) {
|
|
112
154
|
if (errCode(error) === "EXDEV") {
|
|
113
155
|
try {
|
|
114
|
-
await tempHandle.close();
|
|
115
156
|
await copyFile(tempPath, targetPath);
|
|
116
157
|
await rm(tempPath, { force: true });
|
|
158
|
+
await syncDir(dir);
|
|
117
159
|
return;
|
|
118
160
|
} catch {
|
|
119
161
|
try { await rm(tempPath, { force: true }); } catch {}
|
package/src/hash-store.ts
CHANGED
|
@@ -40,8 +40,7 @@ let exitHandlerRegistered = false;
|
|
|
40
40
|
function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
41
41
|
const db = new DatabaseSync(storePath, {
|
|
42
42
|
timeout: HASH_STORE_BUSY_TIMEOUT,
|
|
43
|
-
|
|
44
|
-
} as any);
|
|
43
|
+
});
|
|
45
44
|
db.exec("PRAGMA journal_mode = WAL");
|
|
46
45
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
47
46
|
db.exec(
|
|
@@ -72,6 +71,36 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
|
72
71
|
return { db, stmts };
|
|
73
72
|
}
|
|
74
73
|
|
|
74
|
+
function isHealthy(db: DatabaseSync): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
|
|
77
|
+
return row?.quick_check === "ok";
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function quarantineStore(storePath: string): Promise<void> {
|
|
84
|
+
const suffix = `.corrupt-${Date.now()}`;
|
|
85
|
+
for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
|
|
86
|
+
try {
|
|
87
|
+
await rename(candidate, `${candidate}${suffix}`);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (errCode(error) !== "ENOENT") {
|
|
90
|
+
console.error("Failed to quarantine corrupt hash store file:", error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function shutdownDb(db: DatabaseSync): void {
|
|
97
|
+
try {
|
|
98
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
db.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
export async function loadHashStore(): Promise<HashStore> {
|
|
76
105
|
const storePath = hashStorePath();
|
|
77
106
|
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
@@ -83,13 +112,27 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
83
112
|
await initHasher();
|
|
84
113
|
await mkdir(hashStoreDir(), { recursive: true });
|
|
85
114
|
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
let existed = existsSync(storePath);
|
|
116
|
+
let opened: { db: DatabaseSync; stmts: Prepared };
|
|
117
|
+
try {
|
|
118
|
+
opened = openDb(storePath);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error("Hash store failed to open, rebuilding:", error);
|
|
121
|
+
await quarantineStore(storePath);
|
|
122
|
+
existed = false;
|
|
123
|
+
opened = openDb(storePath);
|
|
124
|
+
}
|
|
125
|
+
if (!isHealthy(opened.db)) {
|
|
126
|
+
shutdownDb(opened.db);
|
|
127
|
+
await quarantineStore(storePath);
|
|
128
|
+
existed = false;
|
|
129
|
+
opened = openDb(storePath);
|
|
130
|
+
}
|
|
131
|
+
const { db, stmts } = opened;
|
|
88
132
|
|
|
89
133
|
if (!existed) {
|
|
90
134
|
await migrateLegacy(db);
|
|
91
135
|
}
|
|
92
|
-
|
|
93
136
|
cachedDb = { path: storePath, db, stmts };
|
|
94
137
|
|
|
95
138
|
if (!exitHandlerRegistered) {
|
|
@@ -108,11 +151,7 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
108
151
|
|
|
109
152
|
export function shutdownHashStore(): void {
|
|
110
153
|
if (cachedDb) {
|
|
111
|
-
|
|
112
|
-
cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
cachedDb.db.close();
|
|
154
|
+
shutdownDb(cachedDb.db);
|
|
116
155
|
cachedDb = null;
|
|
117
156
|
}
|
|
118
157
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -21,6 +21,9 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
|
|
|
21
21
|
const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
|
|
22
22
|
export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
23
23
|
|
|
24
|
+
export const HASH_SPACE = ALPH.length ** HASH_LEN;
|
|
25
|
+
export const MAX_HASH_LINES = HASH_SPACE;
|
|
26
|
+
|
|
24
27
|
function idxToHash(idx: number): string {
|
|
25
28
|
let out = "";
|
|
26
29
|
for (let j = 0; j < HASH_LEN; j++) {
|
|
@@ -30,7 +33,7 @@ function idxToHash(idx: number): string {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
const HASH_TABLE: string[] = Array.from(
|
|
33
|
-
{ length:
|
|
36
|
+
{ length: HASH_SPACE },
|
|
34
37
|
(_, i) => idxToHash(i),
|
|
35
38
|
);
|
|
36
39
|
|
|
@@ -51,7 +54,7 @@ function canon(line: string): string {
|
|
|
51
54
|
return line.replace(/\r/g, "").trimEnd();
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
const BITSET_WORDS =
|
|
57
|
+
const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
|
|
55
58
|
|
|
56
59
|
function getBit(bits: Uint32Array, idx: number): boolean {
|
|
57
60
|
return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
|
|
@@ -98,7 +101,9 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
|
98
101
|
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
throw new Error(
|
|
104
|
+
throw new Error(
|
|
105
|
+
`[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
|
|
106
|
+
);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
|
|
@@ -175,6 +180,47 @@ function hashToIndex(hash: string): number {
|
|
|
175
180
|
return idx;
|
|
176
181
|
}
|
|
177
182
|
|
|
183
|
+
function findNearestCandidate(
|
|
184
|
+
candidates: { index: number; hash: string }[],
|
|
185
|
+
target: number,
|
|
186
|
+
removedHashes?: Set<string>,
|
|
187
|
+
): number {
|
|
188
|
+
let lo = 0;
|
|
189
|
+
let hi = candidates.length;
|
|
190
|
+
while (lo < hi) {
|
|
191
|
+
const mid = (lo + hi) >>> 1;
|
|
192
|
+
if (candidates[mid]!.index < target) lo = mid + 1;
|
|
193
|
+
else hi = mid;
|
|
194
|
+
}
|
|
195
|
+
let left = lo - 1;
|
|
196
|
+
let right = lo;
|
|
197
|
+
while (left >= 0 || right < candidates.length) {
|
|
198
|
+
let bestPos = -1;
|
|
199
|
+
let bestDist = Infinity;
|
|
200
|
+
if (left >= 0) {
|
|
201
|
+
const candidate = candidates[left]!;
|
|
202
|
+
if (!removedHashes?.has(candidate.hash)) {
|
|
203
|
+
bestPos = left;
|
|
204
|
+
bestDist = target - candidate.index;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (right < candidates.length) {
|
|
208
|
+
const candidate = candidates[right]!;
|
|
209
|
+
if (!removedHashes?.has(candidate.hash)) {
|
|
210
|
+
const dist = candidate.index - target;
|
|
211
|
+
if (dist < bestDist) {
|
|
212
|
+
bestPos = right;
|
|
213
|
+
bestDist = dist;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (bestPos >= 0) return bestPos;
|
|
218
|
+
left--;
|
|
219
|
+
right++;
|
|
220
|
+
}
|
|
221
|
+
return -1;
|
|
222
|
+
}
|
|
223
|
+
|
|
178
224
|
function mapStableHashes(
|
|
179
225
|
oldContent: string,
|
|
180
226
|
oldHashes: string[],
|
|
@@ -211,18 +257,8 @@ function mapStableHashes(
|
|
|
211
257
|
const candidates = contentMap.get(line);
|
|
212
258
|
if (!candidates || candidates.length === 0) continue;
|
|
213
259
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
for (let j = 0; j < candidates.length; j++) {
|
|
217
|
-
if (removedHashes?.has(candidates[j]!.hash)) continue;
|
|
218
|
-
const dist = Math.abs(candidates[j]!.index - i);
|
|
219
|
-
if (dist < bestDist) {
|
|
220
|
-
bestDist = dist;
|
|
221
|
-
bestIdx = j;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
|
|
260
|
+
const bestIdx = findNearestCandidate(candidates, i, removedHashes);
|
|
261
|
+
if (bestIdx < 0) continue;
|
|
226
262
|
const match = candidates.splice(bestIdx, 1)[0]!;
|
|
227
263
|
newHashes[i] = match.hash;
|
|
228
264
|
const matchIdx = hashToIndex(match.hash);
|
package/src/hashline/index.ts
CHANGED
package/src/read.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { Type } from "typebox";
|
|
9
9
|
import { loadFileKindAndText } from "./file-kind";
|
|
10
10
|
import { readNormFile } from "./file-reader";
|
|
11
|
-
import { lineHashes, fmtRegion, HASH_SEP } from "./hashline";
|
|
11
|
+
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
12
12
|
import { toCwd } from "./paths";
|
|
13
13
|
import { abortIf } from "./utils";
|
|
14
14
|
import { fileSnap } from "./file-reader";
|
|
@@ -159,7 +159,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
159
159
|
return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
|
|
160
160
|
}
|
|
161
161
|
const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
162
|
-
rawPath, ctx.cwd, { signal, preloadedFile: file },
|
|
162
|
+
rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
|
|
163
163
|
);
|
|
164
164
|
const preview = await fmtReadPreview(
|
|
165
165
|
normalized,
|
package/src/replace.ts
CHANGED
|
@@ -13,12 +13,11 @@ import {
|
|
|
13
13
|
import { readNormFile } from "./file-reader";
|
|
14
14
|
import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
|
|
15
15
|
import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
|
|
16
|
-
import { MAX_HASH_LINES } from "./constants";
|
|
17
16
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
|
-
import {
|
|
19
|
-
applyEdits,
|
|
17
|
+
import { applyEdits,
|
|
20
18
|
lineHashes,
|
|
21
19
|
resEdits,
|
|
20
|
+
MAX_HASH_LINES,
|
|
22
21
|
type HTEdit,
|
|
23
22
|
} from "./hashline";
|
|
24
23
|
import { toCwd } from "./paths";
|
|
@@ -166,9 +165,11 @@ export interface ExecPipelineOptions {
|
|
|
166
165
|
function collectRemovedHashes(
|
|
167
166
|
resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
|
|
168
167
|
originalHashes: string[],
|
|
168
|
+
skipIndices?: Set<number>,
|
|
169
169
|
): Set<string> {
|
|
170
170
|
const removedHashes = new Set<string>();
|
|
171
|
-
for (const edit of resolved) {
|
|
171
|
+
for (const [index, edit] of resolved.entries()) {
|
|
172
|
+
if (skipIndices?.has(index)) continue;
|
|
172
173
|
const startHash = edit.hash_range_inclusive[0].hash;
|
|
173
174
|
const endHash = edit.hash_range_inclusive[1].hash;
|
|
174
175
|
const startLine = originalHashes.indexOf(startHash);
|
|
@@ -234,16 +235,20 @@ export async function execPipeline(
|
|
|
234
235
|
);
|
|
235
236
|
|
|
236
237
|
const result = anchorResult.content;
|
|
237
|
-
|
|
238
|
-
const removedHashes = collectRemovedHashes(resolved, originalHashes);
|
|
238
|
+
const isNoop = result === originalNormalized;
|
|
239
239
|
|
|
240
240
|
const noPersist = options?.noPersist;
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
|
|
242
|
+
const removedHashes = isNoop
|
|
243
|
+
? undefined
|
|
244
|
+
: collectRemovedHashes(resolved, originalHashes, noopIndices);
|
|
245
|
+
const resultHashes = isNoop
|
|
246
|
+
? originalHashes
|
|
247
|
+
: await lineHashes(result, absolutePath, {
|
|
248
|
+
content: originalNormalized,
|
|
249
|
+
hashes: originalHashes,
|
|
250
|
+
removedHashes,
|
|
251
|
+
}, hashStore, noPersist !== true);
|
|
247
252
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
248
253
|
|
|
249
254
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|