pi-hashline-edit-pro 2.6.1 → 2.6.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 +14 -3
- package/index.ts +1 -1
- package/package.json +2 -1
- package/src/boundary-bypass.ts +26 -0
- package/src/hash-store.ts +68 -16
- package/src/hashline/alphabet.ts +2 -0
- package/src/hashline/apply.ts +3 -1
- package/src/hashline/hash.ts +5 -5
- package/src/hashline/index.ts +1 -0
- package/src/hashline/resolve.ts +2 -2
- package/src/read.ts +1 -1
- package/src/replace-diff.ts +11 -0
- package/src/replace-response.ts +3 -1
- package/src/replace-undo.ts +3 -2
- package/src/replace.ts +20 -4
- package/src/served.ts +32 -11
package/README.md
CHANGED
|
@@ -96,11 +96,13 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
|
|
|
96
96
|
Notes:
|
|
97
97
|
|
|
98
98
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
99
|
-
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
|
|
100
|
-
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
99
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
|
|
100
|
+
- An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the exact same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally — the duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that exact payload; any applied edit clears it.
|
|
101
101
|
- Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
|
|
102
102
|
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
103
103
|
- Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
|
|
104
|
+
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
|
|
105
|
+
- Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
|
|
104
106
|
|
|
105
107
|
## Undo
|
|
106
108
|
|
|
@@ -120,6 +122,14 @@ Enabled by default. After a successful `write` that changes the file, the extens
|
|
|
120
122
|
- Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
|
|
121
123
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
122
124
|
|
|
125
|
+
## Tool result details
|
|
126
|
+
|
|
127
|
+
All three tools return machine-readable metadata in `details` alongside the model-visible text:
|
|
128
|
+
|
|
129
|
+
- `read`: `details.truncation` (set when the output was truncated), `details.snapshotId` (a `v2|path|ino|mtime|ctime|size` fingerprint of the file), `details.nextOffset` (use as the next `offset`), and `details.metrics` with `truncated` and `next_offset`.
|
|
130
|
+
- `replace`: `details.diff` (the post-edit diff; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
|
|
131
|
+
- `undo_last_replace`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes), and `details.metrics` (same shape as `replace`).
|
|
132
|
+
|
|
123
133
|
## Settings
|
|
124
134
|
|
|
125
135
|
| Command | Description |
|
|
@@ -144,7 +154,7 @@ Unique anchors by construction. If a line's base hash collides with an already-a
|
|
|
144
154
|
|
|
145
155
|
Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
|
|
146
156
|
|
|
147
|
-
The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows). `replace` verifies every line of the resolved range against that record before writing; a line whose hash
|
|
157
|
+
The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
|
|
148
158
|
|
|
149
159
|
Two guarantees make this safe even with duplicated content:
|
|
150
160
|
|
|
@@ -171,6 +181,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
171
181
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
172
182
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
173
183
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
|
|
184
|
+
| `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
|
|
174
185
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
|
|
175
186
|
|
|
176
187
|
## Troubleshooting
|
package/index.ts
CHANGED
|
@@ -88,7 +88,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
88
88
|
DEFAULT_MAX_BYTES,
|
|
89
89
|
AUTO_READ_MAX,
|
|
90
90
|
);
|
|
91
|
-
await recordServedSafe(absolutePath, preview.servedHashes, "auto-read");
|
|
91
|
+
await recordServedSafe(absolutePath, preview.servedHashes, "auto-read", new Set(fileHashes));
|
|
92
92
|
return {
|
|
93
93
|
content: [
|
|
94
94
|
...(event.content ?? []),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"test": "vitest run",
|
|
56
|
+
"test:unit": "vitest run --config vitest.unit.config.ts",
|
|
56
57
|
"test:watch": "vitest",
|
|
57
58
|
"test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
|
|
58
59
|
"lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const boundaryBypassTracker = new Map<string, string>();
|
|
2
|
+
|
|
3
|
+
export function noopPayloadKey(
|
|
4
|
+
absolutePath: string,
|
|
5
|
+
removeFrom: string,
|
|
6
|
+
removeTo: string,
|
|
7
|
+
replacementLines: string[],
|
|
8
|
+
): string {
|
|
9
|
+
return JSON.stringify([absolutePath, removeFrom, removeTo, replacementLines]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function markBoundaryNoop(absolutePath: string, payload: string): void {
|
|
13
|
+
boundaryBypassTracker.set(absolutePath, payload);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
|
|
17
|
+
if (boundaryBypassTracker.get(absolutePath) === payload) {
|
|
18
|
+
boundaryBypassTracker.delete(absolutePath);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearBoundaryBypass(absolutePath: string): void {
|
|
25
|
+
boundaryBypassTracker.delete(absolutePath);
|
|
26
|
+
}
|
package/src/hash-store.ts
CHANGED
|
@@ -1,13 +1,67 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { readFile, rename, mkdir, stat } from "fs/promises";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
4
3
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
5
4
|
import { errCode, isRec, splitLines } from "./utils";
|
|
6
5
|
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
7
6
|
import { HASH_RE } from "./hashline/alphabet";
|
|
8
7
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
|
+
|
|
9
9
|
type SqlParams = (string | number)[];
|
|
10
10
|
|
|
11
|
+
interface RawStatement {
|
|
12
|
+
get(...params: SqlParams): unknown;
|
|
13
|
+
all(...params: SqlParams): unknown;
|
|
14
|
+
run(...params: SqlParams): unknown;
|
|
15
|
+
}
|
|
16
|
+
interface RawDb {
|
|
17
|
+
exec(sql: string): void;
|
|
18
|
+
prepare(sql: string): RawStatement;
|
|
19
|
+
close(): void;
|
|
20
|
+
readonly isOpen: boolean;
|
|
21
|
+
}
|
|
22
|
+
export type SqliteEngine = "node:sqlite" | "bun:sqlite";
|
|
23
|
+
|
|
24
|
+
interface BunDbLike {
|
|
25
|
+
exec(sql: string): void;
|
|
26
|
+
prepare(sql: string): {
|
|
27
|
+
get(...params: SqlParams): unknown;
|
|
28
|
+
all(...params: SqlParams): unknown[];
|
|
29
|
+
run(...params: SqlParams): unknown;
|
|
30
|
+
};
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let openDbFn: (path: string) => RawDb;
|
|
35
|
+
let sqliteEngine: SqliteEngine;
|
|
36
|
+
|
|
37
|
+
if (typeof process !== "undefined" && (process.versions as Record<string, string | undefined>).bun) {
|
|
38
|
+
const specifier = "bun:sqlite";
|
|
39
|
+
const mod = await import(specifier) as { Database: new (path: string) => BunDbLike };
|
|
40
|
+
sqliteEngine = "bun:sqlite";
|
|
41
|
+
openDbFn = (path) => {
|
|
42
|
+
const db = new mod.Database(path);
|
|
43
|
+
db.exec(`PRAGMA busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
|
|
44
|
+
let closed = false;
|
|
45
|
+
return {
|
|
46
|
+
exec: (sql) => db.exec(sql),
|
|
47
|
+
prepare: (sql) => {
|
|
48
|
+
const stmt = db.prepare(sql);
|
|
49
|
+
return {
|
|
50
|
+
get: (...p) => stmt.get(...p) ?? undefined,
|
|
51
|
+
all: (...p) => stmt.all(...p),
|
|
52
|
+
run: (...p) => stmt.run(...p),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
close: () => { if (!closed) { closed = true; db.close(); } },
|
|
56
|
+
get isOpen() { return !closed; },
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
} else {
|
|
60
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
61
|
+
sqliteEngine = "node:sqlite";
|
|
62
|
+
openDbFn = (path) => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb;
|
|
63
|
+
}
|
|
64
|
+
|
|
11
65
|
interface Prepared {
|
|
12
66
|
get: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
13
67
|
allPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
@@ -24,7 +78,7 @@ interface Prepared {
|
|
|
24
78
|
|
|
25
79
|
export interface HashStore {
|
|
26
80
|
readonly stmts: Prepared;
|
|
27
|
-
readonly engine:
|
|
81
|
+
readonly engine: SqliteEngine;
|
|
28
82
|
}
|
|
29
83
|
|
|
30
84
|
export interface UndoRecord {
|
|
@@ -116,11 +170,11 @@ function withBusyRetry<T>(fn: () => T): T {
|
|
|
116
170
|
throw lastError;
|
|
117
171
|
}
|
|
118
172
|
|
|
119
|
-
function openDbWithBusyRetry(storePath: string): { db:
|
|
173
|
+
function openDbWithBusyRetry(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
120
174
|
return withBusyRetry(() => openDb(storePath));
|
|
121
175
|
}
|
|
122
176
|
|
|
123
|
-
let cachedDb: { path: string; db:
|
|
177
|
+
let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
|
|
124
178
|
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
125
179
|
let exitHandlerRegistered = false;
|
|
126
180
|
interface SnapshotCacheEntry {
|
|
@@ -130,10 +184,8 @@ interface SnapshotCacheEntry {
|
|
|
130
184
|
}
|
|
131
185
|
const snapshotCache = new Map<string, SnapshotCacheEntry>();
|
|
132
186
|
export const SNAPSHOT_CACHE_LIMIT = 256;
|
|
133
|
-
function openDb(storePath: string): { db:
|
|
134
|
-
const db =
|
|
135
|
-
timeout: HASH_STORE_BUSY_TIMEOUT,
|
|
136
|
-
});
|
|
187
|
+
function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
188
|
+
const db = openDbFn(storePath);
|
|
137
189
|
try {
|
|
138
190
|
return buildStore(db);
|
|
139
191
|
} catch (error) {
|
|
@@ -145,8 +197,8 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
|
145
197
|
}
|
|
146
198
|
|
|
147
199
|
function buildStore(
|
|
148
|
-
db:
|
|
149
|
-
): { db:
|
|
200
|
+
db: RawDb,
|
|
201
|
+
): { db: RawDb; stmts: Prepared } {
|
|
150
202
|
db.exec("PRAGMA journal_mode = WAL");
|
|
151
203
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
152
204
|
db.exec(
|
|
@@ -229,7 +281,7 @@ function buildStore(
|
|
|
229
281
|
return { db, stmts };
|
|
230
282
|
}
|
|
231
283
|
|
|
232
|
-
function isHealthy(db:
|
|
284
|
+
function isHealthy(db: RawDb): boolean {
|
|
233
285
|
try {
|
|
234
286
|
const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
|
|
235
287
|
return row?.quick_check === "ok";
|
|
@@ -252,7 +304,7 @@ async function quarantineStore(storePath: string): Promise<void> {
|
|
|
252
304
|
}
|
|
253
305
|
}
|
|
254
306
|
|
|
255
|
-
function shutdownDb(db:
|
|
307
|
+
function shutdownDb(db: RawDb): void {
|
|
256
308
|
try {
|
|
257
309
|
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
258
310
|
} catch {
|
|
@@ -267,7 +319,7 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
267
319
|
await mkdir(hashStoreDir(), { recursive: true });
|
|
268
320
|
|
|
269
321
|
let existed = existsSync(storePath);
|
|
270
|
-
let opened: { db:
|
|
322
|
+
let opened: { db: RawDb; stmts: Prepared };
|
|
271
323
|
try {
|
|
272
324
|
opened = openDbWithBusyRetry(storePath);
|
|
273
325
|
} catch (error) {
|
|
@@ -305,13 +357,13 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
305
357
|
}
|
|
306
358
|
}
|
|
307
359
|
|
|
308
|
-
return { stmts, engine:
|
|
360
|
+
return { stmts, engine: sqliteEngine };
|
|
309
361
|
}
|
|
310
362
|
|
|
311
363
|
export function loadHashStore(): Promise<HashStore> {
|
|
312
364
|
const storePath = hashStorePath();
|
|
313
365
|
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
314
|
-
return Promise.resolve({ stmts: cachedDb.stmts, engine:
|
|
366
|
+
return Promise.resolve({ stmts: cachedDb.stmts, engine: sqliteEngine });
|
|
315
367
|
}
|
|
316
368
|
if (opening && opening.path === storePath) {
|
|
317
369
|
return opening.promise;
|
|
@@ -347,7 +399,7 @@ function withStore(fn: () => void): void {
|
|
|
347
399
|
});
|
|
348
400
|
}
|
|
349
401
|
|
|
350
|
-
async function migrateLegacy(db:
|
|
402
|
+
async function migrateLegacy(db: RawDb): Promise<void> {
|
|
351
403
|
const legacyPath = legacyHashStorePath();
|
|
352
404
|
let content: string;
|
|
353
405
|
try {
|
package/src/hashline/alphabet.ts
CHANGED
package/src/hashline/apply.ts
CHANGED
|
@@ -147,6 +147,7 @@ export function applyEdit(
|
|
|
147
147
|
precomputedHashes?: string[],
|
|
148
148
|
filePath?: string,
|
|
149
149
|
servedHashes?: ReadonlySet<string>,
|
|
150
|
+
skipBoundaryDedup?: boolean,
|
|
150
151
|
): {
|
|
151
152
|
content: string;
|
|
152
153
|
firstChangedLine: number | undefined;
|
|
@@ -188,7 +189,7 @@ export function applyEdit(
|
|
|
188
189
|
|
|
189
190
|
let resolved = initialResolved;
|
|
190
191
|
let autoFixes: AutoFix[] | undefined;
|
|
191
|
-
if (boundaryDups.length > 0) {
|
|
192
|
+
if (boundaryDups.length > 0 && !skipBoundaryDedup) {
|
|
192
193
|
autoFixes = [];
|
|
193
194
|
const correctedEdit: HEdit = {
|
|
194
195
|
...prefixFixed,
|
|
@@ -241,6 +242,7 @@ export function applyEdit(
|
|
|
241
242
|
firstChangedLine: undefined,
|
|
242
243
|
lastChangedLine: undefined,
|
|
243
244
|
...(warnings.length ? { warnings } : {}),
|
|
245
|
+
...(autoFixes ? { autoFixes } : {}),
|
|
244
246
|
noopEdit: { loc: spanResult.loc, currentContent: spanResult.currentContent },
|
|
245
247
|
};
|
|
246
248
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
upsertSnapshot,
|
|
7
7
|
} from "../hash-store";
|
|
8
8
|
import { xxh32, contentChecksum, initHasher } from "./hasher";
|
|
9
|
-
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS } from "./alphabet";
|
|
10
|
-
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS };
|
|
9
|
+
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
|
|
10
|
+
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
|
|
11
11
|
|
|
12
12
|
export const ANCHOR_LEN = HASH_LEN;
|
|
13
13
|
|
|
@@ -39,13 +39,13 @@ function hashAt(idx: number): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
42
|
-
`^\\+${
|
|
42
|
+
`^\\+${HASH_RUN}│`,
|
|
43
43
|
);
|
|
44
44
|
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
45
|
-
`^-(?:${
|
|
45
|
+
`^-(?:${HASH_RUN}│| {${ANCHOR_LEN}}│)`,
|
|
46
46
|
);
|
|
47
47
|
|
|
48
|
-
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${
|
|
48
|
+
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
|
|
49
49
|
|
|
50
50
|
export function canon(line: string): string {
|
|
51
51
|
return line.replace(/\r/g, "").trimEnd();
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
-
import {
|
|
2
|
+
import { HASH_SEP, HASH_RUN, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
@@ -162,7 +162,7 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${
|
|
165
|
+
const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
166
166
|
|
|
167
167
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
168
168
|
assertItem(edit as Record<string, unknown>);
|
package/src/read.ts
CHANGED
|
@@ -216,7 +216,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
216
216
|
fileHashes,
|
|
217
217
|
resolvedPath,
|
|
218
218
|
);
|
|
219
|
-
await recordServedSafe(resolvedPath, preview.servedHashes, "read");
|
|
219
|
+
await recordServedSafe(resolvedPath, preview.servedHashes, "read", new Set(fileHashes));
|
|
220
220
|
const snapshotId = await safeSnapId(absolutePath, "read");
|
|
221
221
|
const previewText =
|
|
222
222
|
hadUtf8DecodeErrors
|
package/src/replace-diff.ts
CHANGED
|
@@ -139,3 +139,14 @@ export function genDiff(
|
|
|
139
139
|
|
|
140
140
|
return { diff: output.join("\n"), firstChangedLine };
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
export function genPatch(
|
|
144
|
+
path: string,
|
|
145
|
+
oldContent: string,
|
|
146
|
+
newContent: string,
|
|
147
|
+
): string {
|
|
148
|
+
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
|
|
149
|
+
context: 4,
|
|
150
|
+
headerOptions: Diff.FILE_HEADERS_ONLY,
|
|
151
|
+
});
|
|
152
|
+
}
|
package/src/replace-response.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReplaceDetails } from "./replace";
|
|
2
|
-
import { genDiff } from "./replace-diff";
|
|
2
|
+
import { genDiff, genPatch } from "./replace-diff";
|
|
3
3
|
import { visLines, clipLine } from "./utils";
|
|
4
4
|
|
|
5
5
|
type TResult = {
|
|
@@ -114,6 +114,7 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
114
114
|
content: [{ type: "text", text }],
|
|
115
115
|
details: {
|
|
116
116
|
diff: "",
|
|
117
|
+
patch: "",
|
|
117
118
|
firstChangedLine: undefined,
|
|
118
119
|
snapshotId,
|
|
119
120
|
classification: "noop" as const,
|
|
@@ -154,6 +155,7 @@ export function buildChanged(input: SuccessInput): TResult {
|
|
|
154
155
|
content: [{ type: "text", text }],
|
|
155
156
|
details: {
|
|
156
157
|
diff: diffResult.diff,
|
|
158
|
+
patch: genPatch(path, originalNormalized, result),
|
|
157
159
|
firstChangedLine:
|
|
158
160
|
editMeta.firstChangedLine ?? diffResult.firstChangedLine,
|
|
159
161
|
snapshotId,
|
package/src/replace-undo.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { recordServedDiff } from "./served";
|
|
|
7
7
|
import { contentChecksum } from "./hashline/hasher";
|
|
8
8
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
9
9
|
import { toCwd } from "./paths";
|
|
10
|
-
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
|
|
10
|
+
import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
|
|
11
11
|
import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { buildMetrics } from "./replace-response";
|
|
@@ -170,7 +170,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
170
170
|
try {
|
|
171
171
|
const store = await loadHashStore();
|
|
172
172
|
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
|
|
173
|
-
recordServedDiff(store, mutationTargetPath, undoDiff);
|
|
173
|
+
recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
|
|
174
174
|
} catch (error) {
|
|
175
175
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
176
176
|
}
|
|
@@ -198,6 +198,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
198
198
|
],
|
|
199
199
|
details: {
|
|
200
200
|
diff: undoDiff,
|
|
201
|
+
patch: genPatch(path, currentNormalized, undo.content),
|
|
201
202
|
metrics: buildMetrics({
|
|
202
203
|
classification: "applied",
|
|
203
204
|
editsAttempted: 1,
|
package/src/replace.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { loadP, loadGuide } from "./prompts";
|
|
|
47
47
|
import { saveUndo } from "./replace-undo";
|
|
48
48
|
import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
|
|
49
49
|
import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
|
|
50
|
+
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
50
51
|
|
|
51
52
|
const replacementLinesSchema = Type.Array(
|
|
52
53
|
Type.String({
|
|
@@ -85,6 +86,7 @@ export type ReqParams = {
|
|
|
85
86
|
|
|
86
87
|
export type ReplaceDetails = {
|
|
87
88
|
diff: string;
|
|
89
|
+
patch?: string;
|
|
88
90
|
firstChangedLine?: number;
|
|
89
91
|
snapshotId?: string;
|
|
90
92
|
classification?: "noop";
|
|
@@ -106,6 +108,7 @@ interface PipelineResult {
|
|
|
106
108
|
resultHashes: string[];
|
|
107
109
|
totalAddedLines: number;
|
|
108
110
|
totalRemovedLines: number;
|
|
111
|
+
hadBoundaryDedup: boolean;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
const PREVIEW_DEBOUNCE_MS = 150;
|
|
@@ -178,6 +181,7 @@ export interface ExecPipelineOptions {
|
|
|
178
181
|
signal?: AbortSignal;
|
|
179
182
|
store?: HashStore;
|
|
180
183
|
noPersist?: boolean;
|
|
184
|
+
skipBoundaryDedup?: boolean;
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
function collectRemovedHashes(
|
|
@@ -251,13 +255,14 @@ export async function execPipeline(
|
|
|
251
255
|
originalHashes,
|
|
252
256
|
path,
|
|
253
257
|
served,
|
|
258
|
+
options?.skipBoundaryDedup,
|
|
254
259
|
);
|
|
255
260
|
} catch (error) {
|
|
256
261
|
if (options?.noPersist !== true) {
|
|
257
262
|
if (error instanceof RangeStaleError) {
|
|
258
|
-
await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback");
|
|
263
|
+
await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(originalHashes));
|
|
259
264
|
} else if (error instanceof AnchorMismatchError) {
|
|
260
|
-
await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback");
|
|
265
|
+
await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(originalHashes));
|
|
261
266
|
}
|
|
262
267
|
}
|
|
263
268
|
throw error;
|
|
@@ -297,6 +302,7 @@ export async function execPipeline(
|
|
|
297
302
|
originalHashes,
|
|
298
303
|
totalAddedLines,
|
|
299
304
|
totalRemovedLines,
|
|
305
|
+
hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
|
|
300
306
|
};
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -476,6 +482,8 @@ export function buildToolDef(): ToolDef {
|
|
|
476
482
|
const path = normalizedParams.path;
|
|
477
483
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
478
484
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
485
|
+
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
486
|
+
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
479
487
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
480
488
|
abortIf(signal);
|
|
481
489
|
|
|
@@ -491,21 +499,28 @@ export function buildToolDef(): ToolDef {
|
|
|
491
499
|
firstChangedLine,
|
|
492
500
|
lastChangedLine,
|
|
493
501
|
resultHashes,
|
|
502
|
+
hadBoundaryDedup,
|
|
494
503
|
totalAddedLines,
|
|
495
504
|
totalRemovedLines,
|
|
496
505
|
} = await execPipeline(
|
|
497
506
|
normalizedParams,
|
|
498
507
|
ctx.cwd,
|
|
499
|
-
{ accessMode: constants.R_OK | constants.W_OK, signal },
|
|
508
|
+
{ accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
|
|
500
509
|
);
|
|
501
510
|
|
|
502
511
|
if (resolution) {
|
|
503
512
|
warnings.unshift(resolution.warning);
|
|
504
513
|
}
|
|
514
|
+
if (boundaryBypass && originalNormalized !== result) {
|
|
515
|
+
warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call. Boundary dedup is now restored.");
|
|
516
|
+
}
|
|
505
517
|
|
|
506
518
|
const editsAttempted = 1;
|
|
507
519
|
if (originalNormalized === result) {
|
|
508
520
|
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
521
|
+
if (hadBoundaryDedup) {
|
|
522
|
+
markBoundaryNoop(mutationTargetPath, noopPayload);
|
|
523
|
+
}
|
|
509
524
|
return buildNoop({
|
|
510
525
|
path,
|
|
511
526
|
noopEdit,
|
|
@@ -549,6 +564,7 @@ export function buildToolDef(): ToolDef {
|
|
|
549
564
|
await undo.restore();
|
|
550
565
|
throw error;
|
|
551
566
|
}
|
|
567
|
+
clearBoundaryBypass(mutationTargetPath);
|
|
552
568
|
const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
|
|
553
569
|
|
|
554
570
|
const editMeta: RMeta = {
|
|
@@ -572,7 +588,7 @@ export function buildToolDef(): ToolDef {
|
|
|
572
588
|
};
|
|
573
589
|
const changed = buildChanged(successInput);
|
|
574
590
|
if (changed.details.diff) {
|
|
575
|
-
await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff");
|
|
591
|
+
await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(resultHashes));
|
|
576
592
|
}
|
|
577
593
|
return changed;
|
|
578
594
|
});
|
package/src/served.ts
CHANGED
|
@@ -20,22 +20,41 @@ export function getServed(store: HashStore, path: string): Set<string> | undefin
|
|
|
20
20
|
return new Set(parsed);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function recordServed(
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
export function recordServed(
|
|
24
|
+
store: HashStore,
|
|
25
|
+
path: string,
|
|
26
|
+
hashes: string[],
|
|
27
|
+
scope?: ReadonlySet<string>,
|
|
28
|
+
): void {
|
|
29
|
+
const existing = getServed(store, path);
|
|
30
|
+
if (!existing && hashes.length === 0) return;
|
|
31
|
+
const set = existing ?? new Set<string>();
|
|
26
32
|
let changed = false;
|
|
33
|
+
if (scope) {
|
|
34
|
+
for (const hash of set) {
|
|
35
|
+
if (!scope.has(hash)) {
|
|
36
|
+
set.delete(hash);
|
|
37
|
+
changed = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
27
41
|
for (const hash of hashes) {
|
|
28
|
-
if (!
|
|
29
|
-
|
|
42
|
+
if (!set.has(hash)) {
|
|
43
|
+
set.add(hash);
|
|
30
44
|
changed = true;
|
|
31
45
|
}
|
|
32
46
|
}
|
|
33
47
|
if (!changed) return;
|
|
34
|
-
store.stmts.servedUpsert(path, JSON.stringify([...
|
|
48
|
+
store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
export function recordServedDiff(
|
|
38
|
-
|
|
51
|
+
export function recordServedDiff(
|
|
52
|
+
store: HashStore,
|
|
53
|
+
path: string,
|
|
54
|
+
diff: string,
|
|
55
|
+
scope?: ReadonlySet<string>,
|
|
56
|
+
): void {
|
|
57
|
+
recordServed(store, path, servedHashesFromDiff(diff), scope);
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export function clearServed(store: HashStore, path: string): void {
|
|
@@ -46,11 +65,12 @@ export async function recordServedSafe(
|
|
|
46
65
|
path: string,
|
|
47
66
|
hashes: string[],
|
|
48
67
|
context: string,
|
|
68
|
+
scope?: ReadonlySet<string>,
|
|
49
69
|
): Promise<void> {
|
|
50
|
-
if (hashes.length === 0) return;
|
|
70
|
+
if (hashes.length === 0 && !scope) return;
|
|
51
71
|
try {
|
|
52
72
|
const store = await loadHashStore();
|
|
53
|
-
recordServed(store, path, hashes);
|
|
73
|
+
recordServed(store, path, hashes, scope);
|
|
54
74
|
} catch (error) {
|
|
55
75
|
console.error(`Failed to record served state (${context}):`, error);
|
|
56
76
|
}
|
|
@@ -60,7 +80,8 @@ export async function recordServedDiffSafe(
|
|
|
60
80
|
path: string,
|
|
61
81
|
diff: string,
|
|
62
82
|
context: string,
|
|
83
|
+
scope?: ReadonlySet<string>,
|
|
63
84
|
): Promise<void> {
|
|
64
85
|
if (!diff) return;
|
|
65
|
-
await recordServedSafe(path, servedHashesFromDiff(diff), context);
|
|
86
|
+
await recordServedSafe(path, servedHashesFromDiff(diff), context, scope);
|
|
66
87
|
}
|