pi-hashline-edit-pro 2.4.1 → 2.5.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 +7 -3
- package/index.ts +11 -1
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +1 -0
- package/src/constants.ts +1 -0
- package/src/file-kind.ts +14 -2
- package/src/hash-store.ts +68 -10
- package/src/hashline/apply.ts +21 -5
- package/src/hashline/index.ts +3 -0
- package/src/hashline/resolve.ts +77 -5
- package/src/read.ts +24 -5
- package/src/replace-diff.ts +6 -1
- package/src/replace-undo.ts +2 -0
- package/src/replace.ts +42 -8
- package/src/served.ts +52 -0
package/README.md
CHANGED
|
@@ -63,8 +63,8 @@ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with
|
|
|
63
63
|
|
|
64
64
|
Edge cases:
|
|
65
65
|
|
|
66
|
-
- Images (JPEG, PNG, GIF, WebP) come back as visual attachments.
|
|
67
|
-
- Binary files and directories are rejected with a descriptive error.
|
|
66
|
+
- Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
|
|
67
|
+
- Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text.
|
|
68
68
|
- UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
|
|
69
69
|
- Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
|
|
70
70
|
- BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
|
|
@@ -96,6 +96,7 @@ Notes:
|
|
|
96
96
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
97
97
|
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_text` 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.
|
|
98
98
|
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
99
|
+
- 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.
|
|
99
100
|
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
100
101
|
- 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.
|
|
101
102
|
|
|
@@ -113,7 +114,6 @@ Notes:
|
|
|
113
114
|
|
|
114
115
|
Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
|
|
115
116
|
|
|
116
|
-
- After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
|
|
117
117
|
- After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
|
|
118
118
|
- 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).
|
|
119
119
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
@@ -142,6 +142,8 @@ Unique anchors by construction. If a line's base hash collides with an already-a
|
|
|
142
142
|
|
|
143
143
|
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.
|
|
144
144
|
|
|
145
|
+
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 was never served 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.
|
|
146
|
+
|
|
145
147
|
Two guarantees make this safe even with duplicated content:
|
|
146
148
|
|
|
147
149
|
- An edited range never borrows a hash from a line outside it. Lines outside the replaced range keep their hashes unconditionally, even when their content is byte-identical to lines inside the range.
|
|
@@ -166,11 +168,13 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
166
168
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
167
169
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
168
170
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
171
|
+
| `[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. |
|
|
169
172
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
170
173
|
|
|
171
174
|
## Troubleshooting
|
|
172
175
|
|
|
173
176
|
- Stale anchors. `[E_STALE_ANCHOR]` or `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read. Call `read` for fresh anchors and retry.
|
|
177
|
+
- Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
|
|
174
178
|
- Reset the hash store. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
175
179
|
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
176
180
|
- Config directory moved. On non-Windows platforms, if `XDG_CONFIG_HOME` is set, the config directory (and the hash store inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
|
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
toggleAutoRead,
|
|
14
14
|
} from "./src/config";
|
|
15
15
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
16
|
+
import { recordServed, clearServed } from "./src/served";
|
|
16
17
|
import { readNormFile } from "./src/file-reader";
|
|
17
18
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
18
19
|
import { toCwd } from "./src/paths";
|
|
@@ -61,7 +62,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
61
62
|
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
62
63
|
if (typeof writtenPath === "string") {
|
|
63
64
|
try {
|
|
64
|
-
|
|
65
|
+
const target = await resolveTarget(toCwd(writtenPath, ctx.cwd));
|
|
66
|
+
await clearUndo(target);
|
|
67
|
+
const store = await loadHashStore();
|
|
68
|
+
clearServed(store, target);
|
|
65
69
|
} catch (error) {
|
|
66
70
|
console.error("Failed to clear undo after write:", error);
|
|
67
71
|
}
|
|
@@ -84,6 +88,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
84
88
|
DEFAULT_MAX_BYTES,
|
|
85
89
|
AUTO_READ_MAX,
|
|
86
90
|
);
|
|
91
|
+
try {
|
|
92
|
+
const store = await loadHashStore();
|
|
93
|
+
recordServed(store, absolutePath, preview.servedHashes);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error("Failed to record served state from auto-read:", error);
|
|
96
|
+
}
|
|
87
97
|
return {
|
|
88
98
|
content: [
|
|
89
99
|
...(event.content ?? []),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
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",
|
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
6
6
|
- `replace`: every `\n` in replacement_text separates lines, so a trailing `\n` adds a final empty line. Mirror the removed lines exactly: a range that ends on a blank line must end replacement_text with `\n` (e.g. `"code\n"`), and a replacement whose last line is not blank must not end with `\n`. To add a blank line after a line, end replacement_text with an explicit empty line after it (e.g. `"X\n"` adds a blank after X). A replacement that is only blank lines is written as one `\n` per blank line.
|
|
7
7
|
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
|
|
8
|
+
- `replace`: `[E_RANGE_STALE]` means a line inside the replaced range changed on disk after it was last shown (or was never shown). Nothing was modified; the error lists the current range as `HASH│content` rows, so retry with those anchors and no `read`.
|
|
8
9
|
- `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
|
package/src/constants.ts
CHANGED
|
@@ -2,6 +2,7 @@ export const AUTO_READ_MAX = 2000;
|
|
|
2
2
|
export const SNIFF_BYTES = 8192;
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
export const MAX_READ_LINE_BYTES = 200 * 1024;
|
|
5
|
+
export const MAX_RANGE_STALE_LINES = 100;
|
|
5
6
|
|
|
6
7
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
7
8
|
export const HASH_STORE_VERSION = 5;
|
package/src/file-kind.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { fileTypeFromBuffer } from "file-type";
|
|
|
3
3
|
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
4
|
|
|
5
5
|
const IMG_TYPES = new Set<string>([
|
|
6
|
+
"image/bmp",
|
|
7
|
+
"image/gif",
|
|
6
8
|
"image/jpeg",
|
|
7
9
|
"image/png",
|
|
8
|
-
"image/gif",
|
|
9
10
|
"image/webp",
|
|
10
11
|
]);
|
|
11
12
|
|
|
@@ -39,6 +40,16 @@ function isTextType(mimeType: string): boolean {
|
|
|
39
40
|
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function looksLikeText(sample: Uint8Array): boolean {
|
|
44
|
+
if (sample.includes(0)) return false;
|
|
45
|
+
try {
|
|
46
|
+
new TextDecoder("utf-8", { fatal: true }).decode(sample);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
export type LFile =
|
|
43
54
|
| { kind: "directory" }
|
|
44
55
|
| { kind: "image"; mimeType: string }
|
|
@@ -96,7 +107,8 @@ export async function loadFileKindAndText(
|
|
|
96
107
|
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
97
108
|
if (
|
|
98
109
|
detectedMimeType !== undefined &&
|
|
99
|
-
!isTextType(detectedMimeType)
|
|
110
|
+
!isTextType(detectedMimeType) &&
|
|
111
|
+
!looksLikeText(sample)
|
|
100
112
|
) {
|
|
101
113
|
if (IMG_TYPES.has(detectedMimeType)) {
|
|
102
114
|
return { kind: "image", mimeType: detectedMimeType };
|
package/src/hash-store.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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, splitLines } from "./utils";
|
|
5
|
+
import { errCode, isRec, splitLines } from "./utils";
|
|
6
6
|
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
7
7
|
import { HASH_RE } from "./hashline/alphabet";
|
|
8
8
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
@@ -17,6 +17,9 @@ interface Prepared {
|
|
|
17
17
|
undoUpsert: (...params: SqlParams) => void;
|
|
18
18
|
undoGet: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
19
19
|
undoDelete: (...params: SqlParams) => void;
|
|
20
|
+
servedGet: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
21
|
+
servedUpsert: (...params: SqlParams) => void;
|
|
22
|
+
servedDelete: (...params: SqlParams) => void;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface HashStore {
|
|
@@ -37,11 +40,12 @@ interface LegacySnapshot {
|
|
|
37
40
|
hashes: string[];
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
function isValidHashList(value: unknown): value is string[] {
|
|
43
|
+
export function isValidHashList(value: unknown): value is string[] {
|
|
41
44
|
if (!Array.isArray(value)) return false;
|
|
42
45
|
for (const hash of value) {
|
|
43
46
|
if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
|
|
44
47
|
}
|
|
48
|
+
if (new Set(value).size !== value.length) return false;
|
|
45
49
|
return true;
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -104,6 +108,13 @@ function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prep
|
|
|
104
108
|
let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
|
|
105
109
|
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
106
110
|
let exitHandlerRegistered = false;
|
|
111
|
+
interface SnapshotCacheEntry {
|
|
112
|
+
checksum: string;
|
|
113
|
+
lineCount: number;
|
|
114
|
+
hashes: string[];
|
|
115
|
+
}
|
|
116
|
+
const snapshotCache = new Map<string, SnapshotCacheEntry>();
|
|
117
|
+
export const SNAPSHOT_CACHE_LIMIT = 256;
|
|
107
118
|
function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
108
119
|
const db = new DatabaseSync(storePath, {
|
|
109
120
|
timeout: HASH_STORE_BUSY_TIMEOUT,
|
|
@@ -149,6 +160,13 @@ function buildStore(
|
|
|
149
160
|
"updated_at INTEGER NOT NULL" +
|
|
150
161
|
")"
|
|
151
162
|
);
|
|
163
|
+
db.exec(
|
|
164
|
+
"CREATE TABLE IF NOT EXISTS served (" +
|
|
165
|
+
"path TEXT PRIMARY KEY, " +
|
|
166
|
+
"hashes TEXT NOT NULL, " +
|
|
167
|
+
"updated_at INTEGER NOT NULL" +
|
|
168
|
+
")"
|
|
169
|
+
);
|
|
152
170
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
153
171
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
154
172
|
db.exec("DELETE FROM snapshots");
|
|
@@ -159,7 +177,7 @@ function buildStore(
|
|
|
159
177
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
160
178
|
).run(String(HASH_STORE_VERSION));
|
|
161
179
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
162
|
-
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
|
|
180
|
+
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
|
|
163
181
|
const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
|
|
164
182
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
165
183
|
const upsertStmt = db.prepare(
|
|
@@ -174,6 +192,12 @@ function buildStore(
|
|
|
174
192
|
"SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
|
|
175
193
|
);
|
|
176
194
|
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
195
|
+
const servedGetStmt = db.prepare("SELECT hashes FROM served WHERE path = ?");
|
|
196
|
+
const servedUpsertStmt = db.prepare(
|
|
197
|
+
"INSERT INTO served (path, hashes, updated_at) VALUES (?, ?, ?) " +
|
|
198
|
+
"ON CONFLICT(path) DO UPDATE SET hashes = excluded.hashes, updated_at = excluded.updated_at"
|
|
199
|
+
);
|
|
200
|
+
const servedDelStmt = db.prepare("DELETE FROM served WHERE path = ?");
|
|
177
201
|
const stmts: Prepared = {
|
|
178
202
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
179
203
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
@@ -183,6 +207,9 @@ function buildStore(
|
|
|
183
207
|
undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
|
|
184
208
|
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
185
209
|
undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
|
|
210
|
+
servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
211
|
+
servedUpsert: (...params) => { withBusyRetry(() => { servedUpsertStmt.run(...params); }); },
|
|
212
|
+
servedDelete: (...params) => { withBusyRetry(() => { servedDelStmt.run(...params); }); },
|
|
186
213
|
};
|
|
187
214
|
return { db, stmts };
|
|
188
215
|
}
|
|
@@ -282,6 +309,7 @@ export function shutdownHashStore(): void {
|
|
|
282
309
|
shutdownDb(cachedDb.db);
|
|
283
310
|
cachedDb = null;
|
|
284
311
|
}
|
|
312
|
+
snapshotCache.clear();
|
|
285
313
|
}
|
|
286
314
|
|
|
287
315
|
function withStore(fn: () => void): void {
|
|
@@ -325,11 +353,17 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
|
325
353
|
|
|
326
354
|
const rows: [string, string, number, string, number][] = [];
|
|
327
355
|
for (const [key, value] of Object.entries(raw)) {
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
|
|
356
|
+
if (
|
|
357
|
+
isRec(value) &&
|
|
358
|
+
Array.isArray(value.hashes) &&
|
|
359
|
+
new Set(value.hashes).size !== value.hashes.length
|
|
360
|
+
) {
|
|
361
|
+
console.warn(
|
|
362
|
+
`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`,
|
|
363
|
+
);
|
|
331
364
|
continue;
|
|
332
365
|
}
|
|
366
|
+
if (!isValidSnapshot(value)) continue;
|
|
333
367
|
rows.push([
|
|
334
368
|
key,
|
|
335
369
|
contentChecksum(value.content),
|
|
@@ -359,6 +393,15 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
|
359
393
|
}
|
|
360
394
|
}
|
|
361
395
|
|
|
396
|
+
function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
|
|
397
|
+
snapshotCache.delete(path);
|
|
398
|
+
snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
|
|
399
|
+
if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
|
|
400
|
+
const oldest = snapshotCache.keys().next().value;
|
|
401
|
+
if (oldest !== undefined) snapshotCache.delete(oldest);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
362
405
|
export function getSnapshot(
|
|
363
406
|
store: HashStore,
|
|
364
407
|
path: string,
|
|
@@ -367,17 +410,29 @@ export function getSnapshot(
|
|
|
367
410
|
): string[] | undefined {
|
|
368
411
|
const checksum = contentChecksum(content);
|
|
369
412
|
const lineCount = splitLines(content).length;
|
|
413
|
+
const cached = snapshotCache.get(path);
|
|
414
|
+
if (cached && cached.checksum === checksum && cached.lineCount === lineCount) {
|
|
415
|
+
snapshotCache.delete(path);
|
|
416
|
+
snapshotCache.set(path, cached);
|
|
417
|
+
return cached.hashes.slice();
|
|
418
|
+
}
|
|
370
419
|
const row = store.stmts.get(path, checksum, lineCount);
|
|
371
420
|
if (!row) return undefined;
|
|
421
|
+
let parsed: unknown;
|
|
372
422
|
try {
|
|
373
|
-
|
|
374
|
-
if (isValidHashList(parsed)) return parsed;
|
|
375
|
-
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
376
|
-
return undefined;
|
|
423
|
+
parsed = JSON.parse(row.hashes as string);
|
|
377
424
|
} catch {
|
|
378
425
|
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
426
|
+
snapshotCache.delete(path);
|
|
379
427
|
return undefined;
|
|
380
428
|
}
|
|
429
|
+
if (isValidHashList(parsed)) {
|
|
430
|
+
cacheSnapshot(path, checksum, lineCount, parsed);
|
|
431
|
+
return parsed;
|
|
432
|
+
}
|
|
433
|
+
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
434
|
+
snapshotCache.delete(path);
|
|
435
|
+
return undefined;
|
|
381
436
|
}
|
|
382
437
|
|
|
383
438
|
export function upsertSnapshot(
|
|
@@ -388,6 +443,7 @@ export function upsertSnapshot(
|
|
|
388
443
|
hashes: string[],
|
|
389
444
|
): void {
|
|
390
445
|
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
|
|
446
|
+
cacheSnapshot(path, checksum, lineCount, hashes);
|
|
391
447
|
}
|
|
392
448
|
|
|
393
449
|
export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
|
|
@@ -458,7 +514,9 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
458
514
|
withStore(() => {
|
|
459
515
|
for (const path of missing) {
|
|
460
516
|
store.stmts.deleteOne(path);
|
|
517
|
+
snapshotCache.delete(path);
|
|
461
518
|
store.stmts.undoDelete(path);
|
|
519
|
+
store.stmts.servedDelete(path);
|
|
462
520
|
}
|
|
463
521
|
});
|
|
464
522
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
stripDiffPrefixes,
|
|
7
7
|
swapReversedRanges,
|
|
8
8
|
warnUnicodeEsc,
|
|
9
|
-
|
|
9
|
+
fmtMismatchWithHashes,
|
|
10
|
+
AnchorMismatchError,
|
|
11
|
+
assertRangeServed,
|
|
10
12
|
type RHEdit,
|
|
11
13
|
type NEdit,
|
|
12
14
|
type HEdit,
|
|
@@ -144,6 +146,7 @@ export function applyEdit(
|
|
|
144
146
|
signal?: AbortSignal,
|
|
145
147
|
precomputedHashes?: string[],
|
|
146
148
|
filePath?: string,
|
|
149
|
+
servedHashes?: ReadonlySet<string>,
|
|
147
150
|
): {
|
|
148
151
|
content: string;
|
|
149
152
|
firstChangedLine: number | undefined;
|
|
@@ -172,9 +175,13 @@ export function applyEdit(
|
|
|
172
175
|
signal,
|
|
173
176
|
);
|
|
174
177
|
if (mismatches.length || !initialResolved) {
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
const feedback = fmtMismatchWithHashes(
|
|
179
|
+
mismatches,
|
|
180
|
+
lineIndex.fileLines,
|
|
181
|
+
fileHashes,
|
|
182
|
+
filePath,
|
|
177
183
|
);
|
|
184
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
178
185
|
}
|
|
179
186
|
|
|
180
187
|
warnUnicodeEsc(prefixFixed, warnings);
|
|
@@ -211,13 +218,22 @@ export function applyEdit(
|
|
|
211
218
|
signal,
|
|
212
219
|
);
|
|
213
220
|
if (correctedResult.mismatches.length || !correctedResult.resolved) {
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
const feedback = fmtMismatchWithHashes(
|
|
222
|
+
correctedResult.mismatches,
|
|
223
|
+
lineIndex.fileLines,
|
|
224
|
+
fileHashes,
|
|
225
|
+
filePath,
|
|
216
226
|
);
|
|
227
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
217
228
|
}
|
|
218
229
|
resolved = correctedResult.resolved;
|
|
219
230
|
}
|
|
220
231
|
|
|
232
|
+
if (servedHashes) {
|
|
233
|
+
abortIf(signal);
|
|
234
|
+
assertRangeServed(resolved, lineIndex.fileLines, fileHashes, servedHashes, filePath);
|
|
235
|
+
}
|
|
236
|
+
|
|
221
237
|
const spanResult = resToSpan(resolved, content, lineIndex);
|
|
222
238
|
if (spanResult.kind === "noop") {
|
|
223
239
|
return {
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
-
import { HASH_CLASS, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
|
|
2
|
+
import { HASH_CLASS, HASH_SEP, 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
|
-
import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
|
|
4
|
+
import { NEW_CONTENT_NOT_STRING_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
@@ -74,15 +74,16 @@ function assertAligned(
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export function
|
|
77
|
+
export function fmtMismatchWithHashes(
|
|
78
78
|
mismatches: HMismatch[],
|
|
79
79
|
fileLines: string[],
|
|
80
80
|
fileHashes: string[],
|
|
81
81
|
filePath?: string,
|
|
82
|
-
): string {
|
|
82
|
+
): { text: string; hashes: string[] } {
|
|
83
83
|
assertAligned(fileLines, fileHashes, "fmtMismatch");
|
|
84
84
|
|
|
85
85
|
const out: string[] = [];
|
|
86
|
+
const hashes: string[] = [];
|
|
86
87
|
const notFound = mismatches.filter((m) => m.kind === "not_found");
|
|
87
88
|
const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
|
|
88
89
|
|
|
@@ -98,6 +99,7 @@ export function fmtMismatch(
|
|
|
98
99
|
const to = Math.min(fileLines.length, ctx.line + 1);
|
|
99
100
|
const rows: string[] = [];
|
|
100
101
|
for (let ln = from; ln <= to; ln++) {
|
|
102
|
+
hashes.push(fileHashes[ln - 1]!);
|
|
101
103
|
rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
|
|
102
104
|
}
|
|
103
105
|
out.push("");
|
|
@@ -115,6 +117,7 @@ export function fmtMismatch(
|
|
|
115
117
|
(m.candidates?.length ?? 0) > sample.length
|
|
116
118
|
? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
|
|
117
119
|
: "";
|
|
120
|
+
for (const line of sample) hashes.push(fileHashes[line - 1]!);
|
|
118
121
|
const lines = sample
|
|
119
122
|
.map((line) => {
|
|
120
123
|
const content = clipLine(fileLines[line - 1] ?? "");
|
|
@@ -127,7 +130,16 @@ export function fmtMismatch(
|
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
return out.join("\n");
|
|
133
|
+
return { text: out.join("\n"), hashes };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function fmtMismatch(
|
|
137
|
+
mismatches: HMismatch[],
|
|
138
|
+
fileLines: string[],
|
|
139
|
+
fileHashes: string[],
|
|
140
|
+
filePath?: string,
|
|
141
|
+
): string {
|
|
142
|
+
return fmtMismatchWithHashes(mismatches, fileLines, fileHashes, filePath).text;
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
const ITEM_KS = new Set(["replacement_text", "remove_from", "remove_to"]);
|
|
@@ -476,4 +488,64 @@ export function valEdit(
|
|
|
476
488
|
};
|
|
477
489
|
}
|
|
478
490
|
|
|
491
|
+
export class RangeStaleError extends Error {
|
|
492
|
+
readonly firstMismatchLine: number;
|
|
493
|
+
readonly rangeHashes: string[];
|
|
494
|
+
constructor(message: string, firstMismatchLine: number, rangeHashes: string[]) {
|
|
495
|
+
super(message);
|
|
496
|
+
this.name = "RangeStaleError";
|
|
497
|
+
this.firstMismatchLine = firstMismatchLine;
|
|
498
|
+
this.rangeHashes = rangeHashes;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export class AnchorMismatchError extends Error {
|
|
503
|
+
readonly feedbackHashes: string[];
|
|
504
|
+
constructor(message: string, feedbackHashes: string[]) {
|
|
505
|
+
super(message);
|
|
506
|
+
this.name = "AnchorMismatchError";
|
|
507
|
+
this.feedbackHashes = feedbackHashes;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function assertRangeServed(
|
|
512
|
+
resolved: RHEdit,
|
|
513
|
+
fileLines: string[],
|
|
514
|
+
fileHashes: string[],
|
|
515
|
+
served: ReadonlySet<string>,
|
|
516
|
+
filePath?: string,
|
|
517
|
+
): void {
|
|
518
|
+
assertAligned(fileLines, fileHashes, "assertRangeServed");
|
|
519
|
+
const startLine = resolved.hash_bounds[0].line;
|
|
520
|
+
const endLine = resolved.hash_bounds[1].line;
|
|
521
|
+
const mismatchLines: number[] = [];
|
|
522
|
+
for (let line = startLine; line <= endLine; line++) {
|
|
523
|
+
if (!served.has(fileHashes[line - 1]!)) mismatchLines.push(line);
|
|
524
|
+
}
|
|
525
|
+
if (mismatchLines.length === 0) return;
|
|
526
|
+
|
|
527
|
+
const rangeLength = endLine - startLine + 1;
|
|
528
|
+
const shownLength = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
|
|
529
|
+
const rows: string[] = [];
|
|
530
|
+
const shownHashes: string[] = [];
|
|
531
|
+
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
532
|
+
const hash = fileHashes[line - 1]!;
|
|
533
|
+
shownHashes.push(hash);
|
|
534
|
+
rows.push(`${hash}${HASH_SEP}${fileLines[line - 1]}`);
|
|
535
|
+
}
|
|
536
|
+
const location = filePath ? ` in ${filePath}` : "";
|
|
537
|
+
const first = mismatchLines[0]!;
|
|
538
|
+
const mismatchText =
|
|
539
|
+
mismatchLines.length === 1
|
|
540
|
+
? `Line ${first} of the replaced range (lines ${startLine}-${endLine})${location} does not match`
|
|
541
|
+
: `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
|
|
542
|
+
const capHint =
|
|
543
|
+
rangeLength > shownLength
|
|
544
|
+
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
|
|
545
|
+
: "";
|
|
546
|
+
const message =
|
|
547
|
+
`[E_RANGE_STALE] ${mismatchText} what was previously shown: the file changed on disk after the anchors were read, or the line(s) were never shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
548
|
+
throw new RangeStaleError(message, first, shownHashes);
|
|
549
|
+
}
|
|
550
|
+
|
|
479
551
|
export { warnUnicodeEsc };
|
package/src/read.ts
CHANGED
|
@@ -12,9 +12,10 @@ import { loadFileKindAndText } from "./file-kind";
|
|
|
12
12
|
import { readNormFile } from "./file-reader";
|
|
13
13
|
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
14
|
import { toCwd } from "./paths";
|
|
15
|
-
import { abortIf, isRec, normalizeFilePath } from "./utils";
|
|
15
|
+
import { abortIf, isRec, normalizeFilePath, visLines } from "./utils";
|
|
16
16
|
import { fileSnap } from "./file-reader";
|
|
17
|
-
import {
|
|
17
|
+
import { loadHashStore } from "./hash-store";
|
|
18
|
+
import { recordServed } from "./served";
|
|
18
19
|
import { loadP, loadGuide } from "./prompts";
|
|
19
20
|
import { valAccess } from "./validation";
|
|
20
21
|
|
|
@@ -59,7 +60,7 @@ export async function fmtReadPreview(
|
|
|
59
60
|
path?: string,
|
|
60
61
|
maxLineBytes = MAX_READ_LINE_BYTES,
|
|
61
62
|
maxTruncLines = DEFAULT_MAX_LINES,
|
|
62
|
-
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
|
|
63
|
+
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number; servedHashes: string[] }> {
|
|
63
64
|
const allLines = visLines(text);
|
|
64
65
|
const totalLines = allLines.length;
|
|
65
66
|
const startLine = normPosInt(options.offset, "offset") ?? 1;
|
|
@@ -69,15 +70,18 @@ export async function fmtReadPreview(
|
|
|
69
70
|
const emptyLineHash = allHashes[0] ?? "";
|
|
70
71
|
return {
|
|
71
72
|
text: `${emptyLineHash}${HASH_SEP}\n[File is empty. Use replace to insert content.]`,
|
|
73
|
+
servedHashes: emptyLineHash ? [emptyLineHash] : [],
|
|
72
74
|
};
|
|
73
75
|
}
|
|
74
76
|
return {
|
|
75
77
|
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
|
|
78
|
+
servedHashes: [],
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
if (startLine > totalLines) {
|
|
79
82
|
return {
|
|
80
83
|
text: `Offset ${startLine} is beyond end of file (${totalLines} lines total). Use offset=1 to read from the start, or offset=${totalLines} to read the last line.`,
|
|
84
|
+
servedHashes: [],
|
|
81
85
|
};
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -104,6 +108,11 @@ export async function fmtReadPreview(
|
|
|
104
108
|
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
|
|
105
109
|
const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
|
|
106
110
|
const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
|
|
111
|
+
const oversizedIndexes = new Set(rowSizes.map((row, index) => row.bytes > maxBytes ? index : -1).filter((index) => index >= 0));
|
|
112
|
+
const servedHashes: string[] = [];
|
|
113
|
+
for (let index = 0; index < Math.min(shownRowCount, rows.length); index++) {
|
|
114
|
+
if (!oversizedIndexes.has(index)) servedHashes.push(selectedHashes[index]!);
|
|
115
|
+
}
|
|
107
116
|
const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
|
|
108
117
|
const verb = oversized.length === 1 ? "exceeds" : "exceed";
|
|
109
118
|
const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
|
|
@@ -120,6 +129,7 @@ export async function fmtReadPreview(
|
|
|
120
129
|
text: preview,
|
|
121
130
|
truncation: skippedTruncation.truncated ? skippedTruncation : undefined,
|
|
122
131
|
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
132
|
+
servedHashes,
|
|
123
133
|
};
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -127,6 +137,8 @@ export async function fmtReadPreview(
|
|
|
127
137
|
|
|
128
138
|
let preview = truncation.content;
|
|
129
139
|
let nextOffset: number | undefined;
|
|
140
|
+
const shownCount = truncation.content === "" ? 0 : truncation.content.split("\n").length;
|
|
141
|
+
const servedHashes = selectedHashes.slice(0, shownCount);
|
|
130
142
|
if (truncation.truncated) {
|
|
131
143
|
const endLineDisplay = startLine + truncation.outputLines - 1;
|
|
132
144
|
nextOffset = endLineDisplay + 1;
|
|
@@ -144,6 +156,7 @@ export async function fmtReadPreview(
|
|
|
144
156
|
text: preview,
|
|
145
157
|
truncation: truncation.truncated ? truncation : undefined,
|
|
146
158
|
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
159
|
+
servedHashes,
|
|
147
160
|
};
|
|
148
161
|
}
|
|
149
162
|
|
|
@@ -198,7 +211,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
198
211
|
) => ReturnType<typeof builtinRead.execute>;
|
|
199
212
|
return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
|
|
200
213
|
}
|
|
201
|
-
const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
214
|
+
const { normalized, fileHashes, hadUtf8DecodeErrors, absolutePath: resolvedPath } = await readNormFile(
|
|
202
215
|
rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
|
|
203
216
|
);
|
|
204
217
|
const preview = await fmtReadPreview(
|
|
@@ -208,8 +221,14 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
208
221
|
limit: params.limit,
|
|
209
222
|
},
|
|
210
223
|
fileHashes,
|
|
211
|
-
|
|
224
|
+
resolvedPath,
|
|
212
225
|
);
|
|
226
|
+
try {
|
|
227
|
+
const store = await loadHashStore();
|
|
228
|
+
recordServed(store, resolvedPath, preview.servedHashes);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error("Failed to record served state from read:", error);
|
|
231
|
+
}
|
|
213
232
|
let snapshotId: string | undefined;
|
|
214
233
|
try {
|
|
215
234
|
snapshotId = (await fileSnap(absolutePath)).snapshotId;
|
package/src/replace-diff.ts
CHANGED
|
@@ -96,6 +96,7 @@ export function genDiff(
|
|
|
96
96
|
let linesToShow: (string | symbol)[] = displayLines;
|
|
97
97
|
let skipStart = 0;
|
|
98
98
|
let skipMiddle = 0;
|
|
99
|
+
let skipTail = 0;
|
|
99
100
|
|
|
100
101
|
if (!lastWasChange) {
|
|
101
102
|
skipStart = Math.max(0, displayLines.length - contextLines);
|
|
@@ -104,8 +105,9 @@ export function genDiff(
|
|
|
104
105
|
const tail = displayLines.slice(-contextLines);
|
|
105
106
|
linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
|
|
106
107
|
skipMiddle = displayLines.length - contextLines * 2;
|
|
107
|
-
} else if (linesToShow.length > contextLines) {
|
|
108
|
+
} else if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
108
109
|
linesToShow = linesToShow.slice(0, contextLines);
|
|
110
|
+
skipTail = displayLines.length - contextLines;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
if (skipStart > 0) {
|
|
@@ -125,6 +127,9 @@ export function genDiff(
|
|
|
125
127
|
newLineNum++;
|
|
126
128
|
oldLineNum++;
|
|
127
129
|
}
|
|
130
|
+
if (skipTail > 0) {
|
|
131
|
+
output.push(" ...");
|
|
132
|
+
}
|
|
128
133
|
} else {
|
|
129
134
|
newLineNum += displayLines.length;
|
|
130
135
|
oldLineNum += displayLines.length;
|
package/src/replace-undo.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
6
|
+
import { recordServedDiff } from "./served";
|
|
6
7
|
import { contentChecksum } from "./hashline/hasher";
|
|
7
8
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
9
|
import { toCwd } from "./paths";
|
|
@@ -174,6 +175,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
174
175
|
try {
|
|
175
176
|
const store = await loadHashStore();
|
|
176
177
|
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
|
|
178
|
+
recordServedDiff(store, mutationTargetPath, undoDiff);
|
|
177
179
|
} catch (error) {
|
|
178
180
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
179
181
|
}
|
package/src/replace.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { applyEdit,
|
|
|
20
20
|
resEdit,
|
|
21
21
|
parseHashRef,
|
|
22
22
|
MAX_HASH_LINES,
|
|
23
|
+
RangeStaleError,
|
|
24
|
+
AnchorMismatchError,
|
|
23
25
|
type HEdit,
|
|
24
26
|
type NEdit,
|
|
25
27
|
} from "./hashline";
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
import { loadP, loadGuide } from "./prompts";
|
|
46
48
|
import { saveUndo } from "./replace-undo";
|
|
47
49
|
import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
|
|
50
|
+
import { getServed, recordServed, recordServedDiff } from "./served";
|
|
48
51
|
|
|
49
52
|
const replacementTextSchema = Type.String({
|
|
50
53
|
description:
|
|
@@ -232,13 +235,35 @@ export async function execPipeline(
|
|
|
232
235
|
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist },
|
|
233
236
|
);
|
|
234
237
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
const served = await getServed(hashStore, absolutePath);
|
|
239
|
+
let anchorResult: ReturnType<typeof applyEdit>;
|
|
240
|
+
try {
|
|
241
|
+
anchorResult = applyEdit(
|
|
242
|
+
originalNormalized,
|
|
243
|
+
edit,
|
|
244
|
+
options?.signal,
|
|
245
|
+
originalHashes,
|
|
246
|
+
path,
|
|
247
|
+
served,
|
|
248
|
+
);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (options?.noPersist !== true) {
|
|
251
|
+
if (error instanceof RangeStaleError) {
|
|
252
|
+
try {
|
|
253
|
+
recordServed(hashStore, absolutePath, error.rangeHashes);
|
|
254
|
+
} catch (recordError) {
|
|
255
|
+
console.error("Failed to record served state from range-stale feedback:", recordError);
|
|
256
|
+
}
|
|
257
|
+
} else if (error instanceof AnchorMismatchError) {
|
|
258
|
+
try {
|
|
259
|
+
recordServed(hashStore, absolutePath, error.feedbackHashes);
|
|
260
|
+
} catch (recordError) {
|
|
261
|
+
console.error("Failed to record served state from anchor-mismatch feedback:", recordError);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
242
267
|
|
|
243
268
|
const result = anchorResult.content;
|
|
244
269
|
const isNoop = result === originalNormalized;
|
|
@@ -562,7 +587,16 @@ export function buildToolDef(): ToolDef {
|
|
|
562
587
|
snapshotId: updatedSnapshotId,
|
|
563
588
|
editMeta,
|
|
564
589
|
};
|
|
565
|
-
|
|
590
|
+
const changed = buildChanged(successInput);
|
|
591
|
+
if (changed.details.diff) {
|
|
592
|
+
try {
|
|
593
|
+
const store = await loadHashStore();
|
|
594
|
+
recordServedDiff(store, mutationTargetPath, changed.details.diff);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
console.error("Failed to record served state from post-edit diff:", error);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return changed;
|
|
566
600
|
});
|
|
567
601
|
},
|
|
568
602
|
};
|
package/src/served.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { HashStore } from "./hash-store";
|
|
2
|
+
import { isValidHashList } from "./hash-store";
|
|
3
|
+
import { HASH_CLASS } from "./hashline/alphabet";
|
|
4
|
+
|
|
5
|
+
const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
|
|
6
|
+
|
|
7
|
+
export function servedHashesFromDiff(diff: string): string[] {
|
|
8
|
+
const hashes: string[] = [];
|
|
9
|
+
for (const line of diff.split("\n")) {
|
|
10
|
+
const match = SERVED_DIFF_ROW_RE.exec(line);
|
|
11
|
+
if (match) hashes.push(match[1]!);
|
|
12
|
+
}
|
|
13
|
+
return hashes;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getServed(store: HashStore, path: string): Set<string> | undefined {
|
|
17
|
+
const row = store.stmts.servedGet(path);
|
|
18
|
+
if (!row) return undefined;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(row.hashes as string);
|
|
21
|
+
if (!isValidHashList(parsed)) {
|
|
22
|
+
store.stmts.servedDelete(path);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return new Set(parsed);
|
|
26
|
+
} catch {
|
|
27
|
+
store.stmts.servedDelete(path);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recordServed(store: HashStore, path: string, hashes: string[]): void {
|
|
33
|
+
if (hashes.length === 0) return;
|
|
34
|
+
const existing = getServed(store, path) ?? new Set<string>();
|
|
35
|
+
let changed = false;
|
|
36
|
+
for (const hash of hashes) {
|
|
37
|
+
if (!existing.has(hash)) {
|
|
38
|
+
existing.add(hash);
|
|
39
|
+
changed = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!changed) return;
|
|
43
|
+
store.stmts.servedUpsert(path, JSON.stringify([...existing]), Date.now());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function recordServedDiff(store: HashStore, path: string, diff: string): void {
|
|
47
|
+
recordServed(store, path, servedHashesFromDiff(diff));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearServed(store: HashStore, path: string): void {
|
|
51
|
+
store.stmts.servedDelete(path);
|
|
52
|
+
}
|