pi-hashline-edit-pro 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/index.ts +11 -1
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +2 -0
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/src/constants.ts +1 -0
- package/src/hash-store.ts +22 -2
- 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-undo.ts +2 -0
- package/src/replace.ts +44 -10
- package/src/served.ts +52 -0
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -142,6 +143,8 @@ Unique anchors by construction. If a line's base hash collides with an already-a
|
|
|
142
143
|
|
|
143
144
|
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
145
|
|
|
146
|
+
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.
|
|
147
|
+
|
|
145
148
|
Two guarantees make this safe even with duplicated content:
|
|
146
149
|
|
|
147
150
|
- 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 +169,13 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
166
169
|
| `[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
170
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
168
171
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
172
|
+
| `[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
173
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
170
174
|
|
|
171
175
|
## Troubleshooting
|
|
172
176
|
|
|
173
177
|
- 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.
|
|
178
|
+
- 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
179
|
- 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
180
|
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
176
181
|
- 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.0",
|
|
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",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
- `replace`: remove_from and remove_to take ONLY the bare 3-char hash — read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `HASH│content` row into these fields.
|
|
1
2
|
- `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_text is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_text is deleted from the file.
|
|
2
3
|
- `replace`: keep the range as tight as the change — anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
|
|
3
4
|
- `replace`: to replace a single line, set both remove_from and remove_to to the same hash: remove_from: "<HASH>", remove_to: "<HASH>".
|
|
4
5
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
5
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.
|
|
6
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`.
|
|
7
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.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace lines in a text file via HASH anchors from read,
|
|
1
|
+
Replace lines in a text file via bare 3-char HASH anchors from read — hash only, never line content; anchor exactly the lines that change; one edit per tool call
|
package/prompts/replace.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace a range of lines in a text file, targeted by the 3-char HASH anchors from read
|
|
1
|
+
Replace a range of lines in a text file, targeted by the 3-char HASH anchors from read output. remove_from and remove_to must each be a BARE 3-character hash: copy only the hash from the leftmost column of a read row (row `ve7│function hello() {` means `"remove_from": "ve7"`). Never pass the line content, a code line, or a paragraph into these fields.
|
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/hash-store.ts
CHANGED
|
@@ -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,7 +40,7 @@ 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;
|
|
@@ -149,6 +152,13 @@ function buildStore(
|
|
|
149
152
|
"updated_at INTEGER NOT NULL" +
|
|
150
153
|
")"
|
|
151
154
|
);
|
|
155
|
+
db.exec(
|
|
156
|
+
"CREATE TABLE IF NOT EXISTS served (" +
|
|
157
|
+
"path TEXT PRIMARY KEY, " +
|
|
158
|
+
"hashes TEXT NOT NULL, " +
|
|
159
|
+
"updated_at INTEGER NOT NULL" +
|
|
160
|
+
")"
|
|
161
|
+
);
|
|
152
162
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
153
163
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
154
164
|
db.exec("DELETE FROM snapshots");
|
|
@@ -159,7 +169,7 @@ function buildStore(
|
|
|
159
169
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
160
170
|
).run(String(HASH_STORE_VERSION));
|
|
161
171
|
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");
|
|
172
|
+
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
|
|
163
173
|
const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
|
|
164
174
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
165
175
|
const upsertStmt = db.prepare(
|
|
@@ -174,6 +184,12 @@ function buildStore(
|
|
|
174
184
|
"SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
|
|
175
185
|
);
|
|
176
186
|
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
187
|
+
const servedGetStmt = db.prepare("SELECT hashes FROM served WHERE path = ?");
|
|
188
|
+
const servedUpsertStmt = db.prepare(
|
|
189
|
+
"INSERT INTO served (path, hashes, updated_at) VALUES (?, ?, ?) " +
|
|
190
|
+
"ON CONFLICT(path) DO UPDATE SET hashes = excluded.hashes, updated_at = excluded.updated_at"
|
|
191
|
+
);
|
|
192
|
+
const servedDelStmt = db.prepare("DELETE FROM served WHERE path = ?");
|
|
177
193
|
const stmts: Prepared = {
|
|
178
194
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
179
195
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
@@ -183,6 +199,9 @@ function buildStore(
|
|
|
183
199
|
undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
|
|
184
200
|
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
185
201
|
undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
|
|
202
|
+
servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
203
|
+
servedUpsert: (...params) => { withBusyRetry(() => { servedUpsertStmt.run(...params); }); },
|
|
204
|
+
servedDelete: (...params) => { withBusyRetry(() => { servedDelStmt.run(...params); }); },
|
|
186
205
|
};
|
|
187
206
|
return { db, stmts };
|
|
188
207
|
}
|
|
@@ -459,6 +478,7 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
459
478
|
for (const path of missing) {
|
|
460
479
|
store.stmts.deleteOne(path);
|
|
461
480
|
store.stmts.undoDelete(path);
|
|
481
|
+
store.stmts.servedDelete(path);
|
|
462
482
|
}
|
|
463
483
|
});
|
|
464
484
|
}
|
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-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:
|
|
@@ -52,11 +55,11 @@ const replacementTextSchema = Type.String({
|
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
const removeFromSchema = Type.String({
|
|
55
|
-
description: "3-char HASH from read
|
|
58
|
+
description: "Bare 3-char HASH only (e.g. \"aB3\") — copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
const removeToSchema = Type.String({
|
|
59
|
-
description: "3-char HASH from read
|
|
62
|
+
description: "Bare 3-char HASH only (e.g. \"aB3\") — copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
|
|
60
63
|
});
|
|
61
64
|
|
|
62
65
|
export const editToolSchema = Type.Object(
|
|
@@ -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
|
+
}
|