pi-hashline-edit-pro 2.5.2 → 2.6.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 +8 -8
- package/index.ts +2 -7
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +2 -2
- package/src/config.ts +7 -5
- package/src/constants.ts +3 -3
- package/src/file-reader.ts +12 -0
- package/src/hash-store.ts +30 -25
- package/src/hashline/index.ts +0 -5
- package/src/hashline/parse.ts +17 -8
- package/src/hashline/resolve.ts +11 -19
- package/src/read.ts +6 -23
- package/src/replace-render.ts +3 -2
- package/src/replace-response.ts +1 -1
- package/src/replace-undo.ts +2 -7
- package/src/replace.ts +26 -48
- package/src/served.ts +24 -2
- package/src/utils.ts +9 -0
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ kQm│}
|
|
|
32
32
|
"path": "src/main.ts",
|
|
33
33
|
"remove_from": "szJ",
|
|
34
34
|
"remove_to": "szJ",
|
|
35
|
-
"
|
|
35
|
+
"replacement_lines": [" console.log('hi');"]
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -76,14 +76,14 @@ Edge cases:
|
|
|
76
76
|
|
|
77
77
|
The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
|
|
78
78
|
|
|
79
|
-
One edit per call, with `remove_from`, `remove_to`, and `
|
|
79
|
+
One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at the top level:
|
|
80
80
|
|
|
81
81
|
```json
|
|
82
82
|
{
|
|
83
83
|
"path": "src/main.ts",
|
|
84
84
|
"remove_from": "szJ",
|
|
85
85
|
"remove_to": "kQm",
|
|
86
|
-
"
|
|
86
|
+
"replacement_lines": [" console.log('hi');", "}"]
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
@@ -91,12 +91,12 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_text` at th
|
|
|
91
91
|
| --- | --- |
|
|
92
92
|
| `remove_from` | 3-char hash from `read` output marking the FIRST line to remove (inclusive). |
|
|
93
93
|
| `remove_to` | 3-char hash from `read` output marking the LAST line to remove (inclusive). |
|
|
94
|
-
| `
|
|
94
|
+
| `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element — each element is exactly one line. |
|
|
95
95
|
|
|
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 `
|
|
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
100
|
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
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.
|
|
@@ -157,12 +157,12 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
157
157
|
|
|
158
158
|
| Code | Meaning |
|
|
159
159
|
| --- | --- |
|
|
160
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `
|
|
160
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
|
|
161
161
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char hash. |
|
|
162
162
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
163
163
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
164
|
-
| `[E_INVALID_PATCH]` | A `
|
|
165
|
-
| `[E_BARE_HASH_PREFIX]` | A `
|
|
164
|
+
| `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
|
|
165
|
+
| `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
|
|
166
166
|
| `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
|
|
167
167
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
168
168
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
package/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
toggleAutoRead,
|
|
14
14
|
} from "./src/config";
|
|
15
15
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
16
|
-
import {
|
|
16
|
+
import { recordServedSafe, clearServed } from "./src/served";
|
|
17
17
|
import { readNormFile } from "./src/file-reader";
|
|
18
18
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
19
19
|
import { toCwd } from "./src/paths";
|
|
@@ -88,12 +88,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
88
88
|
DEFAULT_MAX_BYTES,
|
|
89
89
|
AUTO_READ_MAX,
|
|
90
90
|
);
|
|
91
|
-
|
|
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
|
-
}
|
|
91
|
+
await recordServedSafe(absolutePath, preview.servedHashes, "auto-read");
|
|
97
92
|
return {
|
|
98
93
|
content: [
|
|
99
94
|
...(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.6.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,9 +1,9 @@
|
|
|
1
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.
|
|
2
|
-
- `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and
|
|
2
|
+
- `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_lines 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_lines is deleted from the file.
|
|
3
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.
|
|
4
4
|
- `replace`: to replace a single line, set both remove_from and remove_to to the same hash: remove_from: "<HASH>", remove_to: "<HASH>".
|
|
5
5
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
6
|
-
- `replace`:
|
|
6
|
+
- `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element — each element is exactly one 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
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`.
|
|
9
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/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "fs/promises";
|
|
2
2
|
import { configPath } from "./paths";
|
|
3
|
-
import { errCode } from "./utils";
|
|
3
|
+
import { errCode, isRec } from "./utils";
|
|
4
4
|
import { writeAtomic } from "./fs-write";
|
|
5
5
|
|
|
6
6
|
export interface Config {
|
|
@@ -12,10 +12,12 @@ const DEFAULT_CONFIG: Config = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
function parseConfig(content: string): Config {
|
|
15
|
-
const parsed = JSON.parse(content) as
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const parsed = JSON.parse(content) as unknown;
|
|
16
|
+
const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
|
|
17
|
+
if (typeof autoRead !== "boolean") {
|
|
18
|
+
throw new Error("config.json must be an object with a boolean autoRead field");
|
|
19
|
+
}
|
|
20
|
+
return { autoRead };
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
|
package/src/constants.ts
CHANGED
|
@@ -6,6 +6,6 @@ export const MAX_RANGE_STALE_LINES = 100;
|
|
|
6
6
|
|
|
7
7
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
8
8
|
export const HASH_STORE_VERSION = 5;
|
|
9
|
-
export const
|
|
10
|
-
`[E_BAD_SHAPE] "
|
|
11
|
-
+ ` Do not pass
|
|
9
|
+
export const NEW_CONTENT_NOT_ARRAY_MSG =
|
|
10
|
+
`[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one element per line, not a single string.`
|
|
11
|
+
+ ` Do not pass one string with \\n separators — pass an array of lines: ["line1", "line2"]. Use [] to delete a range.`;
|
package/src/file-reader.ts
CHANGED
|
@@ -45,6 +45,18 @@ export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export async function safeSnapId(
|
|
49
|
+
absolutePath: string,
|
|
50
|
+
context: string,
|
|
51
|
+
): Promise<string | undefined> {
|
|
52
|
+
try {
|
|
53
|
+
return (await fileSnap(absolutePath)).snapshotId;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(`Failed to compute snapshot (${context}):`, error);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
export interface ReadNormOptions {
|
|
49
61
|
signal?: AbortSignal;
|
|
50
62
|
accessMode?: number;
|
package/src/hash-store.ts
CHANGED
|
@@ -286,7 +286,11 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
286
286
|
const { db, stmts } = opened;
|
|
287
287
|
|
|
288
288
|
if (!existed) {
|
|
289
|
-
|
|
289
|
+
try {
|
|
290
|
+
await migrateLegacy(db);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error("Hash store migration failed; continuing without legacy import:", error);
|
|
293
|
+
}
|
|
290
294
|
}
|
|
291
295
|
cachedDb = { path: storePath, db, stmts };
|
|
292
296
|
|
|
@@ -328,20 +332,19 @@ export function shutdownHashStore(): void {
|
|
|
328
332
|
}
|
|
329
333
|
|
|
330
334
|
function withStore(fn: () => void): void {
|
|
331
|
-
if (cachedDb) {
|
|
332
|
-
|
|
333
|
-
cachedDb!.db.exec("BEGIN IMMEDIATE");
|
|
334
|
-
try {
|
|
335
|
-
fn();
|
|
336
|
-
cachedDb!.db.exec("COMMIT");
|
|
337
|
-
} catch (e) {
|
|
338
|
-
try { cachedDb!.db.exec("ROLLBACK"); } catch {}
|
|
339
|
-
throw e;
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
} else {
|
|
343
|
-
fn();
|
|
335
|
+
if (!cachedDb) {
|
|
336
|
+
throw new Error("Hash store is not open; transactional update aborted");
|
|
344
337
|
}
|
|
338
|
+
withBusyRetry(() => {
|
|
339
|
+
cachedDb!.db.exec("BEGIN IMMEDIATE");
|
|
340
|
+
try {
|
|
341
|
+
fn();
|
|
342
|
+
cachedDb!.db.exec("COMMIT");
|
|
343
|
+
} catch (e) {
|
|
344
|
+
try { cachedDb!.db.exec("ROLLBACK"); } catch {}
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
345
348
|
}
|
|
346
349
|
|
|
347
350
|
async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
@@ -388,17 +391,19 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
|
388
391
|
]);
|
|
389
392
|
}
|
|
390
393
|
if (rows.length > 0) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
394
|
+
withBusyRetry(() => {
|
|
395
|
+
db.exec("BEGIN IMMEDIATE");
|
|
396
|
+
try {
|
|
397
|
+
const stmt = db.prepare(
|
|
398
|
+
"INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
|
|
399
|
+
);
|
|
400
|
+
for (const row of rows) stmt.run(...row);
|
|
401
|
+
db.exec("COMMIT");
|
|
402
|
+
} catch (e) {
|
|
403
|
+
try { db.exec("ROLLBACK"); } catch {}
|
|
404
|
+
throw e;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
try {
|
package/src/hashline/index.ts
CHANGED
|
@@ -6,9 +6,6 @@ export {
|
|
|
6
6
|
HASH_SPACE,
|
|
7
7
|
HASH_PROBE_STRIDE,
|
|
8
8
|
MAX_HASH_LINES,
|
|
9
|
-
HL_PREFIX_PLUS_RE,
|
|
10
|
-
HL_PREFIX_MINUS_RE,
|
|
11
|
-
HL_BARE_PREFIX_RE,
|
|
12
9
|
lineHashes,
|
|
13
10
|
_lineHashesPure,
|
|
14
11
|
initHasher,
|
|
@@ -22,7 +19,6 @@ export {
|
|
|
22
19
|
} from "./parse";
|
|
23
20
|
|
|
24
21
|
export {
|
|
25
|
-
type RAnchor,
|
|
26
22
|
type HEdit,
|
|
27
23
|
type RHEdit,
|
|
28
24
|
type HTEdit,
|
|
@@ -34,7 +30,6 @@ export {
|
|
|
34
30
|
stripBarePrefixes,
|
|
35
31
|
stripDiffPrefixes,
|
|
36
32
|
swapReversedRanges,
|
|
37
|
-
fmtMismatch,
|
|
38
33
|
findNewEdge,
|
|
39
34
|
assertRangeServed,
|
|
40
35
|
RangeStaleError,
|
package/src/hashline/parse.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
4
|
} from "./hash";
|
|
5
|
-
import {
|
|
5
|
+
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
6
6
|
|
|
7
7
|
export type Anchor = { hash: string };
|
|
8
8
|
|
|
@@ -39,12 +39,21 @@ function parseRef(ref: string): Anchor {
|
|
|
39
39
|
|
|
40
40
|
export const parseHashRef = parseRef;
|
|
41
41
|
|
|
42
|
-
export function parseText(edit: string): string[] {
|
|
43
|
-
if (
|
|
44
|
-
throw new Error(
|
|
42
|
+
export function parseText(edit: string[], warnings?: string[]): string[] {
|
|
43
|
+
if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
|
|
44
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
45
45
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
let split = false;
|
|
48
|
+
for (const line of edit) {
|
|
49
|
+
const normalized = line.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
50
|
+
if (normalized !== line) split = true;
|
|
51
|
+
out.push(...normalized.split("\n"));
|
|
52
|
+
}
|
|
53
|
+
if (split) {
|
|
54
|
+
warnings?.push(
|
|
55
|
+
"[E_BAD_SHAPE] Autocorrected: split replacement_lines element(s) containing embedded newlines into separate lines.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
50
59
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
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 {
|
|
4
|
+
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
@@ -39,7 +39,7 @@ export interface NEdit {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export type HTEdit = {
|
|
42
|
-
|
|
42
|
+
replacement_lines: string[];
|
|
43
43
|
remove_from: string;
|
|
44
44
|
remove_to: string;
|
|
45
45
|
};
|
|
@@ -133,19 +133,11 @@ export function fmtMismatchWithHashes(
|
|
|
133
133
|
return { text: out.join("\n"), hashes };
|
|
134
134
|
}
|
|
135
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;
|
|
143
|
-
}
|
|
144
136
|
|
|
145
|
-
const ITEM_KS = new Set(["
|
|
137
|
+
const ITEM_KS = new Set(["replacement_lines", "remove_from", "remove_to"]);
|
|
146
138
|
|
|
147
139
|
function assertItem(edit: Record<string, unknown>): void {
|
|
148
|
-
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only {
|
|
140
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
|
|
149
141
|
|
|
150
142
|
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
151
143
|
throw new Error(
|
|
@@ -157,11 +149,11 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
157
149
|
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char hash).`,
|
|
158
150
|
);
|
|
159
151
|
}
|
|
160
|
-
if (!("
|
|
161
|
-
throw new Error(`[E_BAD_SHAPE] The edit requires a "
|
|
152
|
+
if (!("replacement_lines" in edit)) {
|
|
153
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" field. Provide the replacement lines as an array of strings (use [] to delete).`);
|
|
162
154
|
}
|
|
163
|
-
if (
|
|
164
|
-
throw new Error(
|
|
155
|
+
if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
|
|
156
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
165
157
|
}
|
|
166
158
|
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
167
159
|
throw new Error(
|
|
@@ -175,7 +167,7 @@ const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
|
|
|
175
167
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
176
168
|
assertItem(edit as Record<string, unknown>);
|
|
177
169
|
|
|
178
|
-
const replaceLines = parseText(edit.
|
|
170
|
+
const replaceLines = parseText(edit.replacement_lines, warnings);
|
|
179
171
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
180
172
|
const trimmed = ref.trim();
|
|
181
173
|
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
@@ -225,7 +217,7 @@ export function stripBarePrefixes(
|
|
|
225
217
|
});
|
|
226
218
|
if (stripped.length === 0) return edit;
|
|
227
219
|
const locations = stripped
|
|
228
|
-
.map((s) => `
|
|
220
|
+
.map((s) => `replacement_lines line ${s.lineIndex + 1}`)
|
|
229
221
|
.join(", ");
|
|
230
222
|
const matchedCount = stripped.filter((s) => s.matched).length;
|
|
231
223
|
const evidence =
|
|
@@ -261,7 +253,7 @@ export function stripDiffPrefixes(
|
|
|
261
253
|
return line;
|
|
262
254
|
});
|
|
263
255
|
if (stripped.length === 0) return edit;
|
|
264
|
-
const locations = stripped.map((i) => `
|
|
256
|
+
const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
|
|
265
257
|
warnings.push(
|
|
266
258
|
`[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
267
259
|
);
|
package/src/read.ts
CHANGED
|
@@ -9,13 +9,11 @@ import {
|
|
|
9
9
|
import { Type } from "typebox";
|
|
10
10
|
import { MAX_READ_LINE_BYTES } from "./constants";
|
|
11
11
|
import { loadFileKindAndText } from "./file-kind";
|
|
12
|
-
import { readNormFile } from "./file-reader";
|
|
12
|
+
import { readNormFile, safeSnapId } from "./file-reader";
|
|
13
13
|
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
14
|
import { toCwd } from "./paths";
|
|
15
|
-
import { abortIf,
|
|
16
|
-
import {
|
|
17
|
-
import { loadHashStore } from "./hash-store";
|
|
18
|
-
import { recordServed } from "./served";
|
|
15
|
+
import { abortIf, makePrepareArguments, visLines } from "./utils";
|
|
16
|
+
import { recordServedSafe } from "./served";
|
|
19
17
|
import { loadP, loadGuide } from "./prompts";
|
|
20
18
|
import { valAccess } from "./validation";
|
|
21
19
|
|
|
@@ -167,12 +165,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
167
165
|
description: R_DESC,
|
|
168
166
|
promptSnippet: R_SNIPPET,
|
|
169
167
|
promptGuidelines: readGuide(),
|
|
170
|
-
prepareArguments: (
|
|
171
|
-
if (!isRec(args)) return args as any;
|
|
172
|
-
const record = { ...args };
|
|
173
|
-
normalizeFilePath(record);
|
|
174
|
-
return record;
|
|
175
|
-
},
|
|
168
|
+
prepareArguments: makePrepareArguments(),
|
|
176
169
|
parameters: Type.Object({
|
|
177
170
|
path: Type.String({
|
|
178
171
|
description: "Path to the file to read (relative or absolute)",
|
|
@@ -223,18 +216,8 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
223
216
|
fileHashes,
|
|
224
217
|
resolvedPath,
|
|
225
218
|
);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
recordServed(store, resolvedPath, preview.servedHashes);
|
|
229
|
-
} catch (error) {
|
|
230
|
-
console.error("Failed to record served state from read:", error);
|
|
231
|
-
}
|
|
232
|
-
let snapshotId: string | undefined;
|
|
233
|
-
try {
|
|
234
|
-
snapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
235
|
-
} catch (error) {
|
|
236
|
-
console.error("Failed to compute snapshot for read:", error);
|
|
237
|
-
}
|
|
219
|
+
await recordServedSafe(resolvedPath, preview.servedHashes, "read");
|
|
220
|
+
const snapshotId = await safeSnapId(absolutePath, "read");
|
|
238
221
|
const previewText =
|
|
239
222
|
hadUtf8DecodeErrors
|
|
240
223
|
? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
|
package/src/replace-render.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function getPreviewInput(
|
|
|
35
35
|
if (
|
|
36
36
|
typeof normalized.remove_from !== "string" ||
|
|
37
37
|
typeof normalized.remove_to !== "string" ||
|
|
38
|
-
|
|
38
|
+
!Array.isArray(normalized.replacement_lines) ||
|
|
39
|
+
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
39
40
|
) {
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
@@ -44,7 +45,7 @@ export function getPreviewInput(
|
|
|
44
45
|
path: normalized.path,
|
|
45
46
|
remove_from: normalized.remove_from,
|
|
46
47
|
remove_to: normalized.remove_to,
|
|
47
|
-
|
|
48
|
+
replacement_lines: normalized.replacement_lines,
|
|
48
49
|
};
|
|
49
50
|
return request;
|
|
50
51
|
}
|
package/src/replace-response.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
101
101
|
? `Replacement for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
|
|
102
102
|
: "The edit produced identical content.";
|
|
103
103
|
|
|
104
|
-
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
|
|
104
|
+
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
|
|
105
105
|
|
|
106
106
|
const metrics = buildMetrics({
|
|
107
107
|
classification: "noop",
|
package/src/replace-undo.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { contentChecksum } from "./hashline/hasher";
|
|
|
8
8
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
9
9
|
import { toCwd } from "./paths";
|
|
10
10
|
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
|
|
11
|
-
import { cntDiff, splitLines, errCode,
|
|
11
|
+
import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { buildMetrics } from "./replace-response";
|
|
14
14
|
import { changedRange, lineHashes } from "./hashline";
|
|
@@ -92,12 +92,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
92
92
|
description: loadP("../prompts/undo-last-replace.md"),
|
|
93
93
|
promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
|
|
94
94
|
promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
|
|
95
|
-
prepareArguments: (
|
|
96
|
-
if (!isRec(args)) return args as any;
|
|
97
|
-
const record = { ...args };
|
|
98
|
-
normalizeFilePath(record);
|
|
99
|
-
return record;
|
|
100
|
-
},
|
|
95
|
+
prepareArguments: makePrepareArguments(),
|
|
101
96
|
parameters: Type.Object({
|
|
102
97
|
path: Type.String({
|
|
103
98
|
description: "Path to the file to undo",
|
package/src/replace.ts
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
restoreEndings,
|
|
12
12
|
type LineEnding,
|
|
13
13
|
} from "./replace-diff";
|
|
14
|
-
import { readNormFile } from "./file-reader";
|
|
14
|
+
import { readNormFile, safeSnapId } from "./file-reader";
|
|
15
15
|
import { normReq } from "./replace-normalize";
|
|
16
|
-
import { isRec, rejectUnknownFields, abortIf,
|
|
16
|
+
import { isRec, rejectUnknownFields, abortIf, makePrepareArguments } from "./utils";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
18
|
import { applyEdit,
|
|
19
19
|
lineHashes,
|
|
@@ -26,7 +26,6 @@ import { applyEdit,
|
|
|
26
26
|
type NEdit,
|
|
27
27
|
} from "./hashline";
|
|
28
28
|
import { toCwd } from "./paths";
|
|
29
|
-
import { fileSnap } from "./file-reader";
|
|
30
29
|
import {
|
|
31
30
|
buildChanged,
|
|
32
31
|
buildNoop,
|
|
@@ -47,12 +46,18 @@ import {
|
|
|
47
46
|
import { loadP, loadGuide } from "./prompts";
|
|
48
47
|
import { saveUndo } from "./replace-undo";
|
|
49
48
|
import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
|
|
50
|
-
import { getServed,
|
|
49
|
+
import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
|
|
51
50
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const replacementLinesSchema = Type.Array(
|
|
52
|
+
Type.String({
|
|
53
|
+
description:
|
|
54
|
+
"One replacement line. Each element is exactly one line; do not embed \\n inside an element — use separate elements.",
|
|
55
|
+
}),
|
|
56
|
+
{
|
|
57
|
+
description:
|
|
58
|
+
"Replacement lines as an array of strings, one element per line. Use [] to delete the range."
|
|
59
|
+
}
|
|
60
|
+
);
|
|
56
61
|
|
|
57
62
|
const removeFromSchema = Type.String({
|
|
58
63
|
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)",
|
|
@@ -67,7 +72,7 @@ export const editToolSchema = Type.Object(
|
|
|
67
72
|
path: Type.Optional(Type.String({ description: "Path to edit. Required — always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
|
|
68
73
|
remove_from: removeFromSchema,
|
|
69
74
|
remove_to: removeToSchema,
|
|
70
|
-
|
|
75
|
+
replacement_lines: replacementLinesSchema,
|
|
71
76
|
},
|
|
72
77
|
{ additionalProperties: false },
|
|
73
78
|
);
|
|
@@ -75,7 +80,7 @@ export type ReqParams = {
|
|
|
75
80
|
path: string;
|
|
76
81
|
remove_from: string;
|
|
77
82
|
remove_to: string;
|
|
78
|
-
|
|
83
|
+
replacement_lines: string[];
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
export type ReplaceDetails = {
|
|
@@ -105,7 +110,7 @@ interface PipelineResult {
|
|
|
105
110
|
|
|
106
111
|
const PREVIEW_DEBOUNCE_MS = 150;
|
|
107
112
|
|
|
108
|
-
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "
|
|
113
|
+
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
109
114
|
|
|
110
115
|
export function assertReq(
|
|
111
116
|
request: unknown,
|
|
@@ -123,10 +128,11 @@ export function assertReq(
|
|
|
123
128
|
if (
|
|
124
129
|
typeof request.remove_from !== "string" ||
|
|
125
130
|
typeof request.remove_to !== "string" ||
|
|
126
|
-
|
|
131
|
+
!Array.isArray(request.replacement_lines) ||
|
|
132
|
+
request.replacement_lines.some((line) => typeof line !== "string")
|
|
127
133
|
) {
|
|
128
134
|
throw new Error(
|
|
129
|
-
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "
|
|
135
|
+
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" at the top level. replacement_lines must be an array of strings, one element per line (use [] to delete).',
|
|
130
136
|
);
|
|
131
137
|
}
|
|
132
138
|
}
|
|
@@ -225,7 +231,7 @@ export async function execPipeline(
|
|
|
225
231
|
{
|
|
226
232
|
remove_from: params.remove_from,
|
|
227
233
|
remove_to: params.remove_to,
|
|
228
|
-
|
|
234
|
+
replacement_lines: params.replacement_lines,
|
|
229
235
|
},
|
|
230
236
|
editWarnings,
|
|
231
237
|
);
|
|
@@ -249,17 +255,9 @@ export async function execPipeline(
|
|
|
249
255
|
} catch (error) {
|
|
250
256
|
if (options?.noPersist !== true) {
|
|
251
257
|
if (error instanceof RangeStaleError) {
|
|
252
|
-
|
|
253
|
-
recordServed(hashStore, absolutePath, error.rangeHashes);
|
|
254
|
-
} catch (recordError) {
|
|
255
|
-
console.error("Failed to record served state from range-stale feedback:", recordError);
|
|
256
|
-
}
|
|
258
|
+
await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback");
|
|
257
259
|
} else if (error instanceof AnchorMismatchError) {
|
|
258
|
-
|
|
259
|
-
recordServed(hashStore, absolutePath, error.feedbackHashes);
|
|
260
|
-
} catch (recordError) {
|
|
261
|
-
console.error("Failed to record served state from anchor-mismatch feedback:", recordError);
|
|
262
|
-
}
|
|
260
|
+
await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback");
|
|
263
261
|
}
|
|
264
262
|
}
|
|
265
263
|
throw error;
|
|
@@ -361,12 +359,7 @@ export function buildToolDef(): ToolDef {
|
|
|
361
359
|
parameters,
|
|
362
360
|
promptSnippet: E_SNIPPET,
|
|
363
361
|
promptGuidelines: E_GUIDE,
|
|
364
|
-
prepareArguments: (
|
|
365
|
-
if (!isRec(args)) return args as any;
|
|
366
|
-
const record = { ...args };
|
|
367
|
-
normalizeFilePath(record);
|
|
368
|
-
return record;
|
|
369
|
-
},
|
|
362
|
+
prepareArguments: makePrepareArguments(),
|
|
370
363
|
renderShell: "default",
|
|
371
364
|
renderCall(args, theme, context) {
|
|
372
365
|
const previewInput = getPreviewInput(args);
|
|
@@ -512,12 +505,7 @@ export function buildToolDef(): ToolDef {
|
|
|
512
505
|
|
|
513
506
|
const editsAttempted = 1;
|
|
514
507
|
if (originalNormalized === result) {
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
518
|
-
} catch (error) {
|
|
519
|
-
console.error("Failed to compute snapshot for noop edit:", error);
|
|
520
|
-
}
|
|
508
|
+
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
521
509
|
return buildNoop({
|
|
522
510
|
path,
|
|
523
511
|
noopEdit,
|
|
@@ -561,12 +549,7 @@ export function buildToolDef(): ToolDef {
|
|
|
561
549
|
await undo.restore();
|
|
562
550
|
throw error;
|
|
563
551
|
}
|
|
564
|
-
|
|
565
|
-
try {
|
|
566
|
-
updatedSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
567
|
-
} catch (error) {
|
|
568
|
-
console.error("Failed to compute post-edit snapshot:", error);
|
|
569
|
-
}
|
|
552
|
+
const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
|
|
570
553
|
|
|
571
554
|
const editMeta: RMeta = {
|
|
572
555
|
editsAttempted,
|
|
@@ -589,12 +572,7 @@ export function buildToolDef(): ToolDef {
|
|
|
589
572
|
};
|
|
590
573
|
const changed = buildChanged(successInput);
|
|
591
574
|
if (changed.details.diff) {
|
|
592
|
-
|
|
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
|
-
}
|
|
575
|
+
await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff");
|
|
598
576
|
}
|
|
599
577
|
return changed;
|
|
600
578
|
});
|
package/src/served.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { parseHashList } from "./hash-store";
|
|
1
|
+
import { loadHashStore, parseHashList, type HashStore } from "./hash-store";
|
|
3
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
4
3
|
|
|
5
4
|
const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
|
|
@@ -42,3 +41,26 @@ export function recordServedDiff(store: HashStore, path: string, diff: string):
|
|
|
42
41
|
export function clearServed(store: HashStore, path: string): void {
|
|
43
42
|
store.stmts.servedDelete(path);
|
|
44
43
|
}
|
|
44
|
+
|
|
45
|
+
export async function recordServedSafe(
|
|
46
|
+
path: string,
|
|
47
|
+
hashes: string[],
|
|
48
|
+
context: string,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
if (hashes.length === 0) return;
|
|
51
|
+
try {
|
|
52
|
+
const store = await loadHashStore();
|
|
53
|
+
recordServed(store, path, hashes);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(`Failed to record served state (${context}):`, error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function recordServedDiffSafe(
|
|
60
|
+
path: string,
|
|
61
|
+
diff: string,
|
|
62
|
+
context: string,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
if (!diff) return;
|
|
65
|
+
await recordServedSafe(path, servedHashesFromDiff(diff), context);
|
|
66
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -9,6 +9,15 @@ export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function makePrepareArguments(): (args: unknown) => any {
|
|
13
|
+
return (args) => {
|
|
14
|
+
if (!isRec(args)) return args;
|
|
15
|
+
const record = { ...args };
|
|
16
|
+
normalizeFilePath(record);
|
|
17
|
+
return record;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export function splitLines(text: string): string[] {
|
|
13
22
|
if (text.length === 0) return [""];
|
|
14
23
|
const lines = text.split("\n");
|