pi-hashline-edit-pro 0.15.5 → 0.16.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 +5 -5
- package/index.ts +8 -14
- package/package.json +1 -1
- package/prompts/replace-bulk-guidelines.md +3 -2
- package/prompts/replace-bulk.md +3 -3
- package/prompts/replace-flat-guidelines.md +3 -2
- package/prompts/replace-flat.md +4 -1
- package/prompts/replace-guidelines.md +4 -2
- package/prompts/replace.md +4 -1
- package/prompts/undo-last-replace-guidelines.md +3 -0
- package/prompts/undo-last-replace-snippet.md +1 -0
- package/prompts/undo-last-replace.md +1 -0
- package/src/config.ts +2 -12
- package/src/constants.ts +0 -10
- package/src/file-reader.ts +41 -39
- package/src/fs-write.ts +2 -2
- package/src/hash-store.ts +6 -15
- package/src/hashline/apply.ts +0 -18
- package/src/hashline/hash.ts +0 -56
- package/src/paths.ts +19 -0
- package/src/read.ts +0 -5
- package/src/replace-diff.ts +0 -4
- package/src/replace-normalize.ts +73 -31
- package/src/replace-response.ts +6 -16
- package/src/replace-undo.ts +104 -0
- package/src/replace.ts +19 -30
- package/src/undo-store.ts +21 -0
- package/src/utils.ts +25 -11
package/README.md
CHANGED
|
@@ -99,11 +99,11 @@ Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro
|
|
|
99
99
|
The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
|
|
100
100
|
|
|
101
101
|
### Chained edits
|
|
102
|
-
After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present).
|
|
102
|
+
After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present). When auto-read is enabled, fresh anchors are appended automatically. Otherwise call `read` to get fresh anchors for follow-up edits.
|
|
103
103
|
|
|
104
|
-
### Auto-read after write
|
|
104
|
+
### Auto-read after write and replace
|
|
105
105
|
|
|
106
|
-
Auto-read is **disabled by default**. When enabled, after a successful `write` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the
|
|
106
|
+
Auto-read is **disabled by default**. When enabled, after a successful `write` or `replace` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the file without requiring a separate `read` call. The workflow becomes:
|
|
107
107
|
|
|
108
108
|
1. `write` a file, result includes hashline anchors
|
|
109
109
|
2. `replace` using those anchors directly
|
|
@@ -121,7 +121,7 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
|
|
|
121
121
|
| Command | Description |
|
|
122
122
|
| --- | --- |
|
|
123
123
|
| `/toggle-replace-mode` | Switch between bulk mode (`changes` array) and flat mode (top-level fields). Persists across sessions. |
|
|
124
|
-
| `/toggle-auto-read` | Toggle automatic hashline anchors after write operations. Persists across sessions. |
|
|
124
|
+
| `/toggle-auto-read` | Toggle automatic hashline anchors after write and replace operations. Persists across sessions. |
|
|
125
125
|
|
|
126
126
|
### Config file
|
|
127
127
|
|
|
@@ -171,7 +171,7 @@ npm test
|
|
|
171
171
|
|
|
172
172
|
Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
|
|
173
173
|
|
|
174
|
-
Set `PI_HASHLINE_AUTO_READ=1` to enable auto-read after write by default on first run (can still be toggled at runtime with `/toggle-auto-read`; the setting persists across sessions once toggled).
|
|
174
|
+
Set `PI_HASHLINE_AUTO_READ=1` to enable auto-read after write and replace by default on first run (can still be toggled at runtime with `/toggle-auto-read`; the setting persists across sessions once toggled).
|
|
175
175
|
|
|
176
176
|
## Credits
|
|
177
177
|
|
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join, isAbsolute } from "path";
|
|
|
4
4
|
import { initHasher } from "./src/hashline";
|
|
5
5
|
import { regReplace } from "./src/replace";
|
|
6
6
|
import { regReplaceFlat } from "./src/replace-flat";
|
|
7
|
+
import { regReplaceUndo } from "./src/replace-undo";
|
|
7
8
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
8
9
|
import { toLF, stripBOM } from "./src/replace-diff";
|
|
9
10
|
import { visLines } from "./src/utils";
|
|
@@ -19,12 +20,9 @@ import { loadHashStore, pruneHashStore } from "./src/hash-store";
|
|
|
19
20
|
export default function (pi: ExtensionAPI): void {
|
|
20
21
|
regRead(pi);
|
|
21
22
|
|
|
22
|
-
// Register the bulk-mode replace tool by default. The session_start handler
|
|
23
|
-
// will re-register with the correct mode from the persisted config.
|
|
24
23
|
regReplace(pi);
|
|
25
|
-
|
|
24
|
+
regReplaceUndo(pi);
|
|
26
25
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
27
|
-
// Initial auto-read from env var; session_start overrides with persisted value
|
|
28
26
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
29
27
|
let autoRead = autoReadValue === "1" || autoReadValue === "true";
|
|
30
28
|
|
|
@@ -32,12 +30,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
32
30
|
const active = pi.getActiveTools();
|
|
33
31
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
34
32
|
await initHasher();
|
|
35
|
-
// Prune stale snapshots (files that no longer exist) on session start
|
|
36
33
|
try {
|
|
37
34
|
const store = await loadHashStore();
|
|
38
35
|
await pruneHashStore(store);
|
|
39
|
-
} catch {
|
|
40
|
-
// Re-register the replace tool according to the persisted mode
|
|
36
|
+
} catch {}
|
|
41
37
|
const mode = await readReplaceMode();
|
|
42
38
|
if (mode === "flat") {
|
|
43
39
|
regReplaceFlat(pi);
|
|
@@ -45,7 +41,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
45
41
|
regReplace(pi);
|
|
46
42
|
}
|
|
47
43
|
|
|
48
|
-
// Read the persisted auto-read setting (overrides env var default)
|
|
49
44
|
autoRead = await readAutoRead();
|
|
50
45
|
|
|
51
46
|
if (debugValue === "1" || debugValue === "true") {
|
|
@@ -57,7 +52,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
57
52
|
description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
|
|
58
53
|
handler: async (_args, ctx) => {
|
|
59
54
|
const mode = await toggleReplaceMode();
|
|
60
|
-
// Re-register the tool with the new mode
|
|
61
55
|
if (mode === "flat") {
|
|
62
56
|
regReplaceFlat(pi);
|
|
63
57
|
} else {
|
|
@@ -68,10 +62,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
62
|
});
|
|
69
63
|
|
|
70
64
|
pi.registerCommand("toggle-auto-read", {
|
|
71
|
-
description: "Toggle automatic hashline anchors after write operations",
|
|
65
|
+
description: "Toggle automatic hashline anchors after write and replace operations",
|
|
72
66
|
handler: async (_args, ctx) => {
|
|
73
67
|
autoRead = await toggleAutoRead();
|
|
74
|
-
// Re-register the tool so prompts reflect the new auto-read setting
|
|
75
68
|
const mode = await readReplaceMode();
|
|
76
69
|
if (mode === "flat") {
|
|
77
70
|
regReplaceFlat(pi);
|
|
@@ -79,13 +72,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
79
72
|
regReplace(pi);
|
|
80
73
|
}
|
|
81
74
|
const state = autoRead ? "enabled" : "disabled";
|
|
82
|
-
ctx.ui.notify(`Auto-read after write: ${state}`, "info");
|
|
75
|
+
ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
|
|
83
76
|
},
|
|
84
77
|
});
|
|
85
78
|
|
|
86
79
|
pi.on("tool_result", async (event, ctx) => {
|
|
87
80
|
if (!autoRead) return;
|
|
88
|
-
if (event.
|
|
81
|
+
if (event.isError) return;
|
|
82
|
+
if (event.toolName !== "write" && event.toolName !== "replace") return;
|
|
89
83
|
|
|
90
84
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
91
85
|
if (typeof filePath !== "string") return;
|
|
@@ -107,7 +101,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
107
101
|
],
|
|
108
102
|
};
|
|
109
103
|
} catch (error) {
|
|
110
|
-
console.error("Auto-read after write failed:", error);
|
|
104
|
+
console.error("Auto-read after write/replace failed:", error);
|
|
111
105
|
}
|
|
112
106
|
});
|
|
113
107
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
|
|
2
|
-
- After a successful `replace`, the response
|
|
2
|
+
- After a successful `replace`, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
3
3
|
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
|
|
4
4
|
- `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
|
|
5
|
-
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
|
|
5
|
+
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
|
|
6
|
+
- If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
|
package/prompts/replace-bulk.md
CHANGED
|
@@ -94,9 +94,7 @@ Rules:
|
|
|
94
94
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
95
95
|
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
96
96
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
97
|
-
|
|
98
|
-
On success, the response text is empty (or contains only warnings if present). {{AUTO_READ_GUIDANCE}}
|
|
99
|
-
|
|
97
|
+
On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
|
|
100
98
|
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
101
99
|
|
|
102
100
|
Wrong: To replace lines 2-3 in this function:
|
|
@@ -129,3 +127,5 @@ Error recovery:
|
|
|
129
127
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
130
128
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
131
129
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
130
|
+
|
|
131
|
+
**Undo:** If a replace produced incorrect results, call `undo_last_replace` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes. Only one edit per call (flat mode — no `changes` array).
|
|
2
|
-
- After a successful `replace`, the response
|
|
2
|
+
- After a successful `replace`, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
3
3
|
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
|
|
4
4
|
- `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
|
|
5
|
-
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
|
|
5
|
+
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
|
|
6
|
+
- If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
|
package/prompts/replace-flat.md
CHANGED
|
@@ -79,7 +79,8 @@ Rules:
|
|
|
79
79
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
80
80
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
81
81
|
- The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
|
|
82
|
-
|
|
82
|
+
- `hash_range_inclusive` and `content_lines` must be native JSON values, not JSON strings. Do not serialize them — pass them as a proper array and array of strings respectively.
|
|
83
|
+
On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
|
|
83
84
|
|
|
84
85
|
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
85
86
|
|
|
@@ -112,3 +113,5 @@ Error recovery:
|
|
|
112
113
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
113
114
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
114
115
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
116
|
+
|
|
117
|
+
**Undo:** If a replace produced incorrect results, call `undo_last_replace` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
|
|
2
|
-
- After a successful `replace`, the response
|
|
2
|
+
- After a successful `replace`, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
3
3
|
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
|
|
4
4
|
- `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
|
|
5
|
-
-
|
|
5
|
+
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
|
|
6
|
+
- Two modes are available: bulk (default, uses `changes` array) and flat (top-level `hash_range_inclusive`/`content_lines`). Toggle with `/toggle-replace-mode`.
|
|
7
|
+
- If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
|
package/prompts/replace.md
CHANGED
|
@@ -142,7 +142,8 @@ Rules:
|
|
|
142
142
|
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
143
143
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
144
144
|
- The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
|
|
145
|
-
|
|
145
|
+
- `changes` (in bulk mode), `hash_range_inclusive`, and `content_lines` must be native JSON values, not JSON strings. Do not serialize them — pass them as proper arrays and strings.
|
|
146
|
+
On success, the response text shows the line change summary (e.g. "Added 3 line(s), removed 1 line(s).") plus any warnings if present. {{AUTO_READ_GUIDANCE}}
|
|
146
147
|
|
|
147
148
|
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
148
149
|
|
|
@@ -176,3 +177,5 @@ Error recovery:
|
|
|
176
177
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
177
178
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
178
179
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
180
|
+
|
|
181
|
+
**Undo:** If a replace produced incorrect results, call `undo_last_replace` with the file path to revert the last replace. The tool reports how many lines were removed and restored. After undoing, call `read` to get fresh anchors for a corrected replace.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
- Call `undo_last_replace` with the file path to revert the last replace on that file.
|
|
2
|
+
- After undoing, call `read` to get fresh anchors for a corrected replace.
|
|
3
|
+
- Only the most recent replace per file is tracked — calling undo twice without an intervening replace will produce "no undo history".
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Undo the last replace on a file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Undo the last replace operation on a file, reverting it to its previous state. Use this when a replace produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). After undoing, call `read` to get fresh anchors for a corrected replace.
|
package/src/config.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { homedir } from "os";
|
|
3
|
-
import { join } from "path";
|
|
4
2
|
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
3
|
+
import { configDir, configPath } from "./paths";
|
|
5
4
|
|
|
6
5
|
export type ReplaceMode = "bulk" | "flat";
|
|
7
6
|
|
|
@@ -12,18 +11,9 @@ export interface Config {
|
|
|
12
11
|
|
|
13
12
|
const DEFAULT_CONFIG: Config = {
|
|
14
13
|
replaceMode: "bulk",
|
|
15
|
-
autoRead: false
|
|
14
|
+
autoRead: false
|
|
16
15
|
};
|
|
17
16
|
|
|
18
|
-
/** Compute config path lazily so tests can override HOME before calling. */
|
|
19
|
-
function configPath(): string {
|
|
20
|
-
return join(homedir(), ".config", "pi-hashline-edit-pro", "config.json");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function configDir(): string {
|
|
24
|
-
return join(homedir(), ".config", "pi-hashline-edit-pro");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
17
|
export async function readConfig(): Promise<Config> {
|
|
28
18
|
try {
|
|
29
19
|
const content = await readFile(configPath(), "utf-8");
|
package/src/constants.ts
CHANGED
|
@@ -2,14 +2,4 @@ export const AUTO_READ_MAX = 2000;
|
|
|
2
2
|
export const SNIFF_BYTES = 8192;
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Hard ceiling on the number of lines the edit/hash path will process. Guards
|
|
7
|
-
* `lineHashes` (which holds a per-line array plus a Set of every hash) and the
|
|
8
|
-
* host diff against pathological inputs such as a multi-tens-of-MB generated
|
|
9
|
-
* bundle or data dump. Only the `replace` path enforces it (`readNormFile` is
|
|
10
|
-
* called with this limit only from `replace.ts`); `read` keeps its
|
|
11
|
-
* truncate-and-preview behavior. ~1M lines is well above any realistic source
|
|
12
|
-
* file, so this only fires on genuine pathologies. Tunable: lower it to bound
|
|
13
|
-
* hashing cost more tightly.
|
|
14
|
-
*/
|
|
15
5
|
export const MAX_HASH_LINES = 1_000_000;
|
package/src/file-reader.ts
CHANGED
|
@@ -1,58 +1,60 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
2
|
import { lineHashes } from "./hashline";
|
|
3
3
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
4
|
+
import { resolveTarget } from "./fs-write";
|
|
4
5
|
import { toCwd } from "./path-utils";
|
|
5
6
|
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
6
7
|
import { abortIf } from "./runtime";
|
|
7
8
|
import { assertText, valAccess } from "./validation";
|
|
8
9
|
|
|
9
10
|
export interface NormFile {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
absolutePath: string;
|
|
12
|
+
normalized: string;
|
|
13
|
+
bom: string;
|
|
14
|
+
originalEnding: "\r\n" | "\n";
|
|
15
|
+
fileHashes: string[];
|
|
16
|
+
hadUtf8DecodeErrors: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export async function readNormFile(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
path: string,
|
|
21
|
+
cwd: string,
|
|
22
|
+
signal: AbortSignal | undefined,
|
|
23
|
+
accessMode: number = constants.R_OK,
|
|
24
|
+
preloadedFile?: LFile,
|
|
25
|
+
maxLines?: number,
|
|
25
26
|
): Promise<NormFile> {
|
|
26
|
-
|
|
27
|
+
const absolutePath = toCwd(path, cwd);
|
|
28
|
+
const resolvedPath = await resolveTarget(absolutePath);
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
abortIf(signal);
|
|
31
|
+
await valAccess(resolvedPath, path, accessMode);
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
abortIf(signal);
|
|
34
|
+
const file = preloadedFile ?? (await loadFileKindAndText(resolvedPath));
|
|
35
|
+
assertText(file, path);
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
abortIf(signal);
|
|
38
|
+
const { bom, text: rawContent } = stripBOM(file.text);
|
|
39
|
+
const originalEnding = detectEnding(rawContent);
|
|
40
|
+
const normalized = toLF(rawContent);
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
if (maxLines !== undefined) {
|
|
43
|
+
const lineCount = normalized.split("\n").length;
|
|
44
|
+
if (lineCount > maxLines) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
const fileHashes = await lineHashes(normalized, resolvedPath);
|
|
52
|
+
return {
|
|
53
|
+
absolutePath: resolvedPath,
|
|
54
|
+
normalized,
|
|
55
|
+
bom,
|
|
56
|
+
originalEnding,
|
|
57
|
+
fileHashes,
|
|
58
|
+
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
59
|
+
};
|
|
58
60
|
}
|
package/src/fs-write.ts
CHANGED
|
@@ -101,14 +101,14 @@ export async function writeAtomic(
|
|
|
101
101
|
}
|
|
102
102
|
} catch (error: unknown) {
|
|
103
103
|
await tempHandle.close();
|
|
104
|
-
try { await rm(tempPath, { force: true }); } catch {
|
|
104
|
+
try { await rm(tempPath, { force: true }); } catch {}
|
|
105
105
|
throw error;
|
|
106
106
|
}
|
|
107
107
|
try {
|
|
108
108
|
await tempHandle.close();
|
|
109
109
|
await rename(tempPath, targetPath);
|
|
110
110
|
} catch (error: unknown) {
|
|
111
|
-
try { await rm(tempPath, { force: true }); } catch {
|
|
111
|
+
try { await rm(tempPath, { force: true }); } catch {}
|
|
112
112
|
throw error;
|
|
113
113
|
}
|
|
114
114
|
}
|
package/src/hash-store.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
-
import { homedir } from "os";
|
|
3
|
-
import { join, dirname } from "path";
|
|
4
2
|
import { stat } from "fs/promises";
|
|
3
|
+
import { hashStorePath, hashStoreDir } from "./paths";
|
|
5
4
|
|
|
6
5
|
export interface FileSnapshot {
|
|
7
6
|
content: string;
|
|
@@ -13,36 +12,28 @@ export interface HashStore {
|
|
|
13
12
|
snapshots: Record<string, FileSnapshot>;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
function storePath(): string {
|
|
17
|
-
return join(homedir(), ".config", "pi-hashline-edit-pro", "hash-store.json");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function storeDir(): string {
|
|
21
|
-
return dirname(storePath());
|
|
22
|
-
}
|
|
23
|
-
|
|
24
15
|
export async function loadHashStore(): Promise<HashStore> {
|
|
25
16
|
try {
|
|
26
|
-
const content = await readFile(
|
|
17
|
+
const content = await readFile(hashStorePath(), "utf-8");
|
|
27
18
|
const parsed = JSON.parse(content) as Partial<HashStore>;
|
|
28
19
|
return {
|
|
29
20
|
version: 1,
|
|
30
21
|
snapshots: parsed.snapshots ?? {},
|
|
31
22
|
};
|
|
32
23
|
} catch {
|
|
33
|
-
await mkdir(
|
|
24
|
+
await mkdir(hashStoreDir(), { recursive: true });
|
|
34
25
|
const defaultStore: HashStore = {
|
|
35
26
|
version: 1,
|
|
36
27
|
snapshots: {},
|
|
37
28
|
};
|
|
38
|
-
await writeFile(
|
|
29
|
+
await writeFile(hashStorePath(), JSON.stringify(defaultStore), "utf-8");
|
|
39
30
|
return defaultStore;
|
|
40
31
|
}
|
|
41
32
|
}
|
|
42
33
|
|
|
43
34
|
export async function saveHashStore(store: HashStore): Promise<void> {
|
|
44
|
-
await mkdir(
|
|
45
|
-
await writeFile(
|
|
35
|
+
await mkdir(hashStoreDir(), { recursive: true });
|
|
36
|
+
await writeFile(hashStorePath(), JSON.stringify(store, null, 2), "utf-8");
|
|
46
37
|
}
|
|
47
38
|
|
|
48
39
|
export async function pruneHashStore(store: HashStore): Promise<void> {
|
package/src/hashline/apply.ts
CHANGED
|
@@ -212,14 +212,6 @@ function assemble(
|
|
|
212
212
|
return result;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/**
|
|
216
|
-
* Builds the boundary-duplication warning shown after an edit. A minimal header
|
|
217
|
-
* is followed by a hashline-anchored window: 2 lines of context before the
|
|
218
|
-
* duplicated pair, the pair itself, and 2 lines after. The window carries the
|
|
219
|
-
* post-edit hashes the model needs to remove the duplicate in a follow-up
|
|
220
|
-
* `replace` (no `read` round-trip required, since the hashes are current and
|
|
221
|
-
* staleness is per-line). Rows are plain `HASH│content` — no annotations.
|
|
222
|
-
*/
|
|
223
215
|
export function fmtBoundaryWarning(params: {
|
|
224
216
|
kind: "trailing" | "leading";
|
|
225
217
|
survivingContent: string;
|
|
@@ -232,10 +224,6 @@ export function fmtBoundaryWarning(params: {
|
|
|
232
224
|
? "Boundary duplication (trailing): the last replacement line duplicated the next line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there."
|
|
233
225
|
: "Boundary duplication (leading): the first replacement line duplicated the previous line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there.";
|
|
234
226
|
|
|
235
|
-
// Locate the adjacent duplicated pair (two identical neighboring lines). The
|
|
236
|
-
// occurrence-based matchIndex usually lands inside the pair; when a file has
|
|
237
|
-
// several identical lines we pick the pair nearest matchIndex so the window
|
|
238
|
-
// frames the duplication this edit introduced, not an unrelated one.
|
|
239
227
|
let pairStart = -1;
|
|
240
228
|
let bestDist = Infinity;
|
|
241
229
|
for (let i = 0; i < params.resultLines.length - 1; i++) {
|
|
@@ -284,12 +272,6 @@ export function applyEdits(
|
|
|
284
272
|
lastChangedLine: undefined,
|
|
285
273
|
};
|
|
286
274
|
|
|
287
|
-
edits = edits.map((edit) =>
|
|
288
|
-
edit.content_lines.length === 1 &&
|
|
289
|
-
edit.content_lines[0] === ""
|
|
290
|
-
? { ...edit, content_lines: [] }
|
|
291
|
-
: edit,
|
|
292
|
-
);
|
|
293
275
|
|
|
294
276
|
const lineIndex = buildIdx(content);
|
|
295
277
|
const fileHashes = precomputedHashes ?? _lineHashesPure(content);
|
package/src/hashline/hash.ts
CHANGED
|
@@ -5,11 +5,6 @@ import { loadHashStore, saveHashStore } from "../hash-store";
|
|
|
5
5
|
export const HASH_LEN = 3;
|
|
6
6
|
export const ANCHOR_LEN = HASH_LEN;
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
* The `│` (U+2502) delimiter between a hash anchor and its line content. This
|
|
10
|
-
* is the wire-format separator in `HASH│content` rows. Kept as a constant so
|
|
11
|
-
* every construction site resolves the same delimiter.
|
|
12
|
-
*/
|
|
13
8
|
export const HASH_SEP = "│";
|
|
14
9
|
|
|
15
10
|
const ALPH =
|
|
@@ -74,17 +69,6 @@ function canon(line: string): string {
|
|
|
74
69
|
return line.replace(/\r/g, "").trimEnd();
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
/**
|
|
78
|
-
* Pure hash computation — no I/O, no persistence. Used internally by the
|
|
79
|
-
* stable path and as a fallback when no file path is available.
|
|
80
|
-
* Each line is hashed with xxHash32 over its canonical form (trailing
|
|
81
|
-
* whitespace and CR characters stripped). If the base hash collides with a
|
|
82
|
-
* hash already assigned to an earlier line, a retry counter (`:R{retry}`)
|
|
83
|
-
* is appended to the canonical content and the hash is recomputed until a
|
|
84
|
-
* unique anchor is found. This perfect-hashing step guarantees every line
|
|
85
|
-
* in a file receives a distinct anchor, even when multiple lines contain
|
|
86
|
-
* identical text (e.g. repeated `}` or `import` statements).
|
|
87
|
-
*/
|
|
88
72
|
export function _lineHashesPure(content: string): string[] {
|
|
89
73
|
const lines = content.split("\n");
|
|
90
74
|
const hashes = new Array<string>(lines.length);
|
|
@@ -103,24 +87,6 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
103
87
|
return hashes;
|
|
104
88
|
}
|
|
105
89
|
|
|
106
|
-
/**
|
|
107
|
-
* Stable, persistent-aware hash computation.
|
|
108
|
-
*
|
|
109
|
-
* When `path` is provided, uses the persistent hash store to preserve
|
|
110
|
-
* hashes for unchanged lines across edits. When `previous` is also
|
|
111
|
-
* provided (called from the replace pipeline), diffs the previous content
|
|
112
|
-
* against the new content and copies hashes for unchanged lines to their
|
|
113
|
-
* new positions. New/changed lines allocate fresh hashes with collision
|
|
114
|
-
* avoidance against the preserved set.
|
|
115
|
-
*
|
|
116
|
-
* When `path` is provided without `previous` (called from read), loads
|
|
117
|
-
* the stored snapshot for that path. If the content matches, returns the
|
|
118
|
-
* saved hashes. Otherwise computes fresh hashes via `_lineHashesPure` and
|
|
119
|
-
* saves a new snapshot.
|
|
120
|
-
*
|
|
121
|
-
* When `path` is not provided, falls back to `_lineHashesPure` (for
|
|
122
|
-
* backward compatibility and tests that don't need persistence).
|
|
123
|
-
*/
|
|
124
90
|
export async function lineHashes(
|
|
125
91
|
content: string,
|
|
126
92
|
path?: string,
|
|
@@ -132,7 +98,6 @@ export async function lineHashes(
|
|
|
132
98
|
|
|
133
99
|
const store = await loadHashStore();
|
|
134
100
|
|
|
135
|
-
// Case 1: previous content provided (replace pipeline) — diff and preserve
|
|
136
101
|
if (previous) {
|
|
137
102
|
const newHashes = mapStableHashes(
|
|
138
103
|
previous.content, previous.hashes,
|
|
@@ -144,28 +109,17 @@ export async function lineHashes(
|
|
|
144
109
|
return newHashes;
|
|
145
110
|
}
|
|
146
111
|
|
|
147
|
-
// Case 2: no previous — check snapshot or compute fresh
|
|
148
112
|
const snapshot = store.snapshots[path];
|
|
149
113
|
if (snapshot && snapshot.content === content) {
|
|
150
114
|
return snapshot.hashes;
|
|
151
115
|
}
|
|
152
116
|
|
|
153
|
-
// Compute fresh hashes
|
|
154
117
|
const newHashes = _lineHashesPure(content);
|
|
155
118
|
store.snapshots[path] = { content, hashes: newHashes };
|
|
156
119
|
await saveHashStore(store);
|
|
157
120
|
return newHashes;
|
|
158
121
|
}
|
|
159
122
|
|
|
160
|
-
/**
|
|
161
|
-
* Maps old hashes to new positions using hash-aware content matching.
|
|
162
|
-
* Unlike Diff.diffLines (which is content-only and cannot distinguish
|
|
163
|
-
* identical lines at different positions), this algorithm uses the
|
|
164
|
-
* removedHashes set to disambiguate: when a line appears multiple times
|
|
165
|
-
* in the old content and one occurrence was targeted by the edit,
|
|
166
|
-
* the surviving occurrence is matched to the non-removed hash.
|
|
167
|
-
* New/changed lines allocate fresh hashes with collision avoidance.
|
|
168
|
-
*/
|
|
169
123
|
function mapStableHashes(
|
|
170
124
|
oldContent: string,
|
|
171
125
|
oldHashes: string[],
|
|
@@ -176,8 +130,6 @@ function mapStableHashes(
|
|
|
176
130
|
const newHashes = new Array<string>(newLines.length);
|
|
177
131
|
const used = new Set<string>();
|
|
178
132
|
|
|
179
|
-
// Build a map from line content to list of (index, hash) for the old content.
|
|
180
|
-
// We process occurrences left-to-right so that matching preserves order.
|
|
181
133
|
const contentMap = new Map<string, { index: number; hash: string }[]>();
|
|
182
134
|
const oldLines = oldContent.split("\n");
|
|
183
135
|
for (let i = 0; i < oldLines.length; i++) {
|
|
@@ -191,17 +143,11 @@ function mapStableHashes(
|
|
|
191
143
|
}
|
|
192
144
|
}
|
|
193
145
|
|
|
194
|
-
// Match each new line to an old occurrence by content.
|
|
195
|
-
// For lines with duplicate content, prefer occurrences whose hash
|
|
196
|
-
// was NOT targeted by the edit (those are the survivors).
|
|
197
146
|
for (let i = 0; i < newLines.length; i++) {
|
|
198
147
|
const line = newLines[i]!;
|
|
199
148
|
const candidates = contentMap.get(line);
|
|
200
149
|
if (!candidates || candidates.length === 0) continue;
|
|
201
150
|
|
|
202
|
-
// Find the best match: prefer a non-removed occurrence.
|
|
203
|
-
// If all remaining occurrences are removed, use the first one anyway
|
|
204
|
-
// (it will get a fresh hash in the fill step since its hash is in used+removed).
|
|
205
151
|
let bestIdx = 0;
|
|
206
152
|
if (removedHashes && removedHashes.size > 0) {
|
|
207
153
|
for (let j = 0; j < candidates.length; j++) {
|
|
@@ -217,7 +163,6 @@ function mapStableHashes(
|
|
|
217
163
|
used.add(match.hash);
|
|
218
164
|
}
|
|
219
165
|
|
|
220
|
-
// Fill remaining (new/changed) lines with fresh hashes
|
|
221
166
|
for (let i = 0; i < newLines.length; i++) {
|
|
222
167
|
if (newHashes[i]) continue;
|
|
223
168
|
const c = canon(newLines[i]!);
|
|
@@ -230,7 +175,6 @@ function mapStableHashes(
|
|
|
230
175
|
used.add(hash);
|
|
231
176
|
newHashes[i] = hash;
|
|
232
177
|
}
|
|
233
|
-
|
|
234
178
|
return newHashes;
|
|
235
179
|
}
|
|
236
180
|
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export function configDir(): string {
|
|
6
|
+
return join(homedir(), ".config", "pi-hashline-edit-pro");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function configPath(): string {
|
|
10
|
+
return join(configDir(), "config.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hashStorePath(): string {
|
|
14
|
+
return join(configDir(), "hash-store.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hashStoreDir(): string {
|
|
18
|
+
return dirname(hashStorePath());
|
|
19
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -41,11 +41,6 @@ function normPosInt(
|
|
|
41
41
|
return value;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
* Builds the `[Showing lines A-B of N. Use offset=M to continue.]` hint shared by
|
|
46
|
-
* `fmtReadPreview` and the auto-read-after-write handler. `byteLimit` adds the
|
|
47
|
-
* `(size limit)` suffix used when truncation is byte-driven.
|
|
48
|
-
*/
|
|
49
44
|
export function formatPaginationHint(
|
|
50
45
|
startLine: number,
|
|
51
46
|
endLine: number,
|
package/src/replace-diff.ts
CHANGED
|
@@ -47,10 +47,6 @@ export function genDiff(
|
|
|
47
47
|
newContentHashes?: string[],
|
|
48
48
|
oldHashes?: string[],
|
|
49
49
|
): { diff: string; firstChangedLine: number | undefined } {
|
|
50
|
-
// Run Diff.diffLines on raw content only (no hash annotations) so that
|
|
51
|
-
// lines whose content is identical are never reported as changed even
|
|
52
|
-
// when their hash differs due to collision resolution or position
|
|
53
|
-
// tracking. Hashes are used purely for display via fmtDiffLine.
|
|
54
50
|
const oldLines = oldContent.split("\n");
|
|
55
51
|
const newLines = newContent.split("\n");
|
|
56
52
|
const effectiveOldHashes = oldHashes ?? _lineHashesPure(oldContent);
|
package/src/replace-normalize.ts
CHANGED
|
@@ -9,38 +9,48 @@ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | und
|
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
|
|
13
|
+
const warnings: string[] = [];
|
|
14
|
+
const result = items
|
|
15
|
+
.map((item: unknown) => {
|
|
16
|
+
if (typeof item === "string") {
|
|
17
|
+
const parsed = tryParseJSON(item, isRec);
|
|
18
|
+
if (parsed) {
|
|
19
|
+
warnings.push("Edit item was passed as a JSON string instead of a native object. Use native JSON values, not serialized strings.");
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return item;
|
|
24
|
+
})
|
|
20
25
|
.map((change: unknown) => {
|
|
21
26
|
if (!isRec(change)) return change;
|
|
22
27
|
if (typeof change.content_lines !== "string") return change;
|
|
23
28
|
const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
|
|
24
29
|
Array.isArray(v) && v.every((i) => typeof i === "string"),
|
|
25
30
|
);
|
|
26
|
-
|
|
31
|
+
if (parsed) {
|
|
32
|
+
warnings.push("content_lines was passed as a JSON string inside an edit item. Use a native array of strings.");
|
|
33
|
+
return { ...change, content_lines: parsed };
|
|
34
|
+
}
|
|
35
|
+
return change;
|
|
27
36
|
});
|
|
37
|
+
return { result, warnings };
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
/**
|
|
31
|
-
* Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
|
|
32
|
-
* single objects → wrapped in array. Shared by the `changes` and `edits`
|
|
33
|
-
* normalization branches.
|
|
34
|
-
*/
|
|
35
40
|
function normalizeField(
|
|
36
41
|
record: Record<string, unknown>,
|
|
37
42
|
from: string,
|
|
38
43
|
to: string,
|
|
39
|
-
):
|
|
40
|
-
if (!has(record, from)) return;
|
|
44
|
+
): string | undefined {
|
|
45
|
+
if (!has(record, from)) return undefined;
|
|
41
46
|
const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
|
|
47
|
+
const wasString = typeof record[from] === "string" && raw !== record[from];
|
|
42
48
|
if (Array.isArray(raw)) {
|
|
43
|
-
|
|
49
|
+
const { result, warnings } = coerceEditArray(raw);
|
|
50
|
+
record[to] = result;
|
|
51
|
+
if (warnings.length > 0) {
|
|
52
|
+
return warnings.join(" ");
|
|
53
|
+
}
|
|
44
54
|
} else {
|
|
45
55
|
const single =
|
|
46
56
|
typeof raw === "string"
|
|
@@ -48,9 +58,32 @@ function normalizeField(
|
|
|
48
58
|
: isRec(raw)
|
|
49
59
|
? raw
|
|
50
60
|
: undefined;
|
|
51
|
-
if (single)
|
|
61
|
+
if (single) {
|
|
62
|
+
const { result, warnings } = coerceEditArray([single]);
|
|
63
|
+
record[to] = result;
|
|
64
|
+
if (warnings.length > 0) {
|
|
65
|
+
return warnings.join(" ");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
52
68
|
}
|
|
53
69
|
if (from !== to) delete record[from];
|
|
70
|
+
if (wasString) {
|
|
71
|
+
return `Field "${from}" was passed as a JSON string instead of a native array. Use native JSON values, not serialized strings.`;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
77
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
78
|
+
record.path = record.file_path;
|
|
79
|
+
delete record.file_path;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function tryParseField(record: Record<string, unknown>, field: string): void {
|
|
84
|
+
if (typeof record[field] === "string") {
|
|
85
|
+
try { record[field] = JSON.parse(record[field] as string); } catch {}
|
|
86
|
+
}
|
|
54
87
|
}
|
|
55
88
|
|
|
56
89
|
export function normReq(input: unknown): unknown {
|
|
@@ -59,25 +92,31 @@ export function normReq(input: unknown): unknown {
|
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
const record: Record<string, unknown> = { ...input };
|
|
95
|
+
const warnings: string[] = [];
|
|
62
96
|
|
|
63
|
-
|
|
64
|
-
record.path = record.file_path;
|
|
65
|
-
delete record.file_path;
|
|
66
|
-
}
|
|
97
|
+
normalizeFilePath(record);
|
|
67
98
|
|
|
68
|
-
normalizeField(record, "changes", "changes");
|
|
69
|
-
|
|
99
|
+
const w1 = normalizeField(record, "changes", "changes");
|
|
100
|
+
if (w1) warnings.push(w1);
|
|
101
|
+
const w2 = normalizeField(record, "edits", "changes");
|
|
102
|
+
if (w2) warnings.push(w2);
|
|
70
103
|
|
|
71
|
-
// Handle flat format: hash_range_inclusive and content_lines at top level
|
|
72
|
-
// (no changes array). Wrap them into a single-element changes array.
|
|
73
104
|
if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
|
|
74
|
-
const
|
|
105
|
+
const hriRaw = record.hash_range_inclusive;
|
|
106
|
+
const hri = tryParseJSON(hriRaw, (v): v is string[] =>
|
|
75
107
|
Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
|
|
76
|
-
) ??
|
|
108
|
+
) ?? hriRaw;
|
|
109
|
+
if (typeof hriRaw === "string" && hri !== hriRaw) {
|
|
110
|
+
warnings.push("hash_range_inclusive was passed as a JSON string instead of a native array. Use native JSON values.");
|
|
111
|
+
}
|
|
77
112
|
|
|
78
|
-
const
|
|
113
|
+
const clRaw = record.content_lines;
|
|
114
|
+
const cl = tryParseJSON(clRaw, (v): v is string[] =>
|
|
79
115
|
Array.isArray(v) && v.every((i) => typeof i === "string")
|
|
80
|
-
) ??
|
|
116
|
+
) ?? clRaw;
|
|
117
|
+
if (typeof clRaw === "string" && cl !== clRaw) {
|
|
118
|
+
warnings.push("content_lines was passed as a JSON string instead of a native array. Use native JSON values.");
|
|
119
|
+
}
|
|
81
120
|
|
|
82
121
|
if (Array.isArray(hri) && Array.isArray(cl)) {
|
|
83
122
|
record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
|
|
@@ -86,6 +125,9 @@ export function normReq(input: unknown): unknown {
|
|
|
86
125
|
}
|
|
87
126
|
}
|
|
88
127
|
|
|
128
|
+
if (warnings.length > 0) {
|
|
129
|
+
(record as Record<string, unknown>)._normWarnings = warnings;
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
return record;
|
|
90
133
|
}
|
|
91
|
-
|
package/src/replace-response.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReplaceDetails } from "./replace";
|
|
2
2
|
import { genDiff } from "./replace-diff";
|
|
3
|
-
import { visLines } from "./utils";
|
|
3
|
+
import { visLines, cntDiff } from "./utils";
|
|
4
4
|
|
|
5
5
|
type TResult = {
|
|
6
6
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -50,19 +50,6 @@ export interface SuccessInput {
|
|
|
50
50
|
editMeta: RMeta;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function cntDiff(diff: string, marker: "+" | "-"): number {
|
|
54
|
-
if (!diff) return 0;
|
|
55
|
-
let count = 0;
|
|
56
|
-
for (const line of diff.split("\n")) {
|
|
57
|
-
if (
|
|
58
|
-
line.startsWith(marker) &&
|
|
59
|
-
!line.startsWith(`${marker}${marker}${marker}`)
|
|
60
|
-
) {
|
|
61
|
-
count += 1;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return count;
|
|
65
|
-
}
|
|
66
53
|
|
|
67
54
|
function buildM(args: {
|
|
68
55
|
classification: "applied" | "noop";
|
|
@@ -148,11 +135,14 @@ export function buildChanged(input: SuccessInput): TResult {
|
|
|
148
135
|
const removedLines = cntDiff(diffResult.diff, "-");
|
|
149
136
|
const warningsBlock = warnBlock(warnings);
|
|
150
137
|
const successPrefix = `Successfully replaced in ${path}.`;
|
|
138
|
+
const lineSummary = addedLines > 0 || removedLines > 0
|
|
139
|
+
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
140
|
+
: "";
|
|
151
141
|
const text = resultLines.length === 0
|
|
152
142
|
? "File is empty. Use replace to insert content."
|
|
153
143
|
: warningsBlock
|
|
154
|
-
? `${successPrefix}${warningsBlock}`
|
|
155
|
-
: successPrefix
|
|
144
|
+
? `${successPrefix}${lineSummary}${warningsBlock}`
|
|
145
|
+
: `${successPrefix}${lineSummary}`;
|
|
156
146
|
|
|
157
147
|
const metrics = buildM({
|
|
158
148
|
classification: "applied",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { getUndo, clearUndo } from "./undo-store";
|
|
6
|
+
import { loadHashStore, saveHashStore } from "./hash-store";
|
|
7
|
+
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
|
+
import { toCwd } from "./path-utils";
|
|
9
|
+
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
10
|
+
import { cntDiff } from "./utils";
|
|
11
|
+
import { loadP, loadGuide } from "./prompts";
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "undo_last_replace",
|
|
17
|
+
label: "Undo Last Replace",
|
|
18
|
+
description: loadP("../prompts/undo-last-replace.md"),
|
|
19
|
+
promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
|
|
20
|
+
promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
path: Type.String({
|
|
23
|
+
description: "Path to the file to undo the last replace on",
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
28
|
+
const path = params.path;
|
|
29
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
30
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
31
|
+
|
|
32
|
+
const undo = getUndo(mutationTargetPath);
|
|
33
|
+
if (!undo) {
|
|
34
|
+
return {
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: `No undo history for ${path}. There is no previous replace to revert.`,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
isError: true,
|
|
42
|
+
details: {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
47
|
+
let currentNormalized = "";
|
|
48
|
+
try {
|
|
49
|
+
const currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
50
|
+
const { text: currentStripped } = stripBOM(currentRaw);
|
|
51
|
+
currentNormalized = toLF(currentStripped);
|
|
52
|
+
} catch {
|
|
53
|
+
currentNormalized = "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
|
57
|
+
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
58
|
+
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
59
|
+
|
|
60
|
+
await writeAtomic(
|
|
61
|
+
mutationTargetPath,
|
|
62
|
+
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const store = await loadHashStore();
|
|
66
|
+
store.snapshots[mutationTargetPath] = {
|
|
67
|
+
content: undo.content,
|
|
68
|
+
hashes: undo.hashes,
|
|
69
|
+
};
|
|
70
|
+
await saveHashStore(store);
|
|
71
|
+
|
|
72
|
+
clearUndo(mutationTargetPath);
|
|
73
|
+
|
|
74
|
+
const parts: string[] = [
|
|
75
|
+
`Undone last replace on ${path}.`,
|
|
76
|
+
];
|
|
77
|
+
if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
|
|
78
|
+
parts.push(
|
|
79
|
+
`Removed ${linesAddedByReplace} line(s) that were added and restored ${linesRemovedByReplace} line(s) that were removed.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
parts.push(
|
|
83
|
+
"File reverted to previous state. Call `read` to get fresh anchors for follow-up edits.",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: parts.join("\n"),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
details: {
|
|
94
|
+
metrics: {
|
|
95
|
+
added_lines: linesRemovedByReplace,
|
|
96
|
+
removed_lines: linesAddedByReplace,
|
|
97
|
+
classification: "applied" as const,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
package/src/replace.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
restoreEndings,
|
|
12
12
|
} from "./replace-diff";
|
|
13
13
|
import { readNormFile } from "./file-reader";
|
|
14
|
-
import { normReq } from "./replace-normalize";
|
|
14
|
+
import { normReq, normalizeFilePath, tryParseField } from "./replace-normalize";
|
|
15
15
|
import { isRec, has, rejectUnknownFields } from "./utils";
|
|
16
16
|
import { MAX_HASH_LINES } from "./constants";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "./replace-render";
|
|
45
45
|
import { loadP, loadGuide } from "./prompts";
|
|
46
46
|
import { readAutoReadSync } from "./config";
|
|
47
|
+
import { saveUndo } from "./undo-store";
|
|
47
48
|
|
|
48
49
|
const contentLinesSchema = Type.Array(Type.String(), {
|
|
49
50
|
description:
|
|
@@ -75,11 +76,6 @@ export const editToolSchema = Type.Object(
|
|
|
75
76
|
{ additionalProperties: false },
|
|
76
77
|
);
|
|
77
78
|
|
|
78
|
-
/**
|
|
79
|
-
* Flat-mode schema: hash_range_inclusive and content_lines are at the top
|
|
80
|
-
* level instead of inside a "changes" array. Only a single edit is supported
|
|
81
|
-
* per call (no bulk changes).
|
|
82
|
-
*/
|
|
83
79
|
export const flatEditToolSchema = Type.Object(
|
|
84
80
|
{
|
|
85
81
|
path: Type.String({ description: "path" }),
|
|
@@ -168,6 +164,7 @@ export async function execPipeline(
|
|
|
168
164
|
);
|
|
169
165
|
|
|
170
166
|
const absolutePath = toCwd(path, cwd);
|
|
167
|
+
const resolvedPath = await resolveTarget(absolutePath);
|
|
171
168
|
const resolved = resEdits(toolEdits);
|
|
172
169
|
const anchorResult = applyEdits(
|
|
173
170
|
originalNormalized,
|
|
@@ -179,12 +176,6 @@ export async function execPipeline(
|
|
|
179
176
|
|
|
180
177
|
const result = anchorResult.content;
|
|
181
178
|
|
|
182
|
-
// Collect hashes targeted by the edit for hash-aware diff disambiguation.
|
|
183
|
-
// Include every hash in the replaced range, not just the boundary anchors,
|
|
184
|
-
// so that a surviving line whose content is identical to an interior line
|
|
185
|
-
// of the edit is not matched to a removed hash.
|
|
186
|
-
// Use originalHashes.indexOf to find line numbers since resEdits returns
|
|
187
|
-
// HEdit[] (Anchor = { hash }) not RHEdit[] (RAnchor = { line, hash }).
|
|
188
179
|
const removedHashes = new Set<string>();
|
|
189
180
|
for (const edit of resolved) {
|
|
190
181
|
const startHash = edit.hash_range_inclusive[0].hash;
|
|
@@ -198,14 +189,12 @@ export async function execPipeline(
|
|
|
198
189
|
}
|
|
199
190
|
}
|
|
200
191
|
|
|
201
|
-
|
|
202
|
-
const resultHashes = await lineHashes(result, absolutePath, {
|
|
192
|
+
const resultHashes = await lineHashes(result, resolvedPath, {
|
|
203
193
|
content: originalNormalized,
|
|
204
194
|
hashes: originalHashes,
|
|
205
195
|
removedHashes,
|
|
206
196
|
});
|
|
207
197
|
|
|
208
|
-
// Format boundary warnings using stable result hashes
|
|
209
198
|
const resultLines = result.split("\n");
|
|
210
199
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
211
200
|
for (const bw of anchorResult.boundaryWarnings ?? []) {
|
|
@@ -297,7 +286,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
297
286
|
export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
298
287
|
const autoRead = readAutoReadSync();
|
|
299
288
|
const readGuidance = autoRead
|
|
300
|
-
? "Anchors are provided automatically after write operations when auto-read is enabled."
|
|
289
|
+
? "Anchors are provided automatically after write and replace operations when auto-read is enabled."
|
|
301
290
|
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
302
291
|
|
|
303
292
|
const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
|
|
@@ -319,20 +308,11 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
319
308
|
promptGuidelines: E_GUIDE,
|
|
320
309
|
prepareArguments: opts.flat
|
|
321
310
|
? (args: unknown) => {
|
|
322
|
-
// Minimal normalization: file_path → path, JSON string parsing.
|
|
323
|
-
// The flat-to-canonical conversion happens in execute().
|
|
324
311
|
if (!isRec(args)) return args as any;
|
|
325
312
|
const record = { ...args };
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
if (typeof record.hash_range_inclusive === "string") {
|
|
331
|
-
try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
|
|
332
|
-
}
|
|
333
|
-
if (typeof record.content_lines === "string") {
|
|
334
|
-
try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
|
|
335
|
-
}
|
|
313
|
+
normalizeFilePath(record);
|
|
314
|
+
tryParseField(record, "hash_range_inclusive");
|
|
315
|
+
tryParseField(record, "content_lines");
|
|
336
316
|
return record as any;
|
|
337
317
|
}
|
|
338
318
|
: (args: unknown) =>
|
|
@@ -426,8 +406,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
426
406
|
},
|
|
427
407
|
|
|
428
408
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
429
|
-
// Flat mode: wrap top-level fields into a single-element changes array.
|
|
430
|
-
// Bulk mode: use params as-is after normReq normalization.
|
|
431
409
|
const canonical = opts.flat
|
|
432
410
|
? normReq({
|
|
433
411
|
path: (params as any).path,
|
|
@@ -437,6 +415,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
437
415
|
}],
|
|
438
416
|
})
|
|
439
417
|
: normReq(params);
|
|
418
|
+
const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
|
|
440
419
|
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
441
420
|
const path = normalizedParams.path;
|
|
442
421
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
@@ -469,6 +448,10 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
469
448
|
? normalizedParams.changes.length
|
|
470
449
|
: 0;
|
|
471
450
|
|
|
451
|
+
if (normWarnings) {
|
|
452
|
+
warnings.push(...normWarnings);
|
|
453
|
+
}
|
|
454
|
+
|
|
472
455
|
if (originalNormalized === result) {
|
|
473
456
|
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
474
457
|
return buildNoop({
|
|
@@ -494,6 +477,12 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
494
477
|
absolutePath,
|
|
495
478
|
bom + restoreEndings(result, originalEnding),
|
|
496
479
|
);
|
|
480
|
+
saveUndo(mutationTargetPath, {
|
|
481
|
+
content: originalNormalized,
|
|
482
|
+
bom,
|
|
483
|
+
originalEnding,
|
|
484
|
+
hashes: originalHashes,
|
|
485
|
+
});
|
|
497
486
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
498
487
|
.snapshotId;
|
|
499
488
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
export interface UndoEntry {
|
|
3
|
+
content: string;
|
|
4
|
+
bom: string;
|
|
5
|
+
originalEnding: "\r\n" | "\n";
|
|
6
|
+
hashes: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const undoMap = new Map<string, UndoEntry>();
|
|
10
|
+
|
|
11
|
+
export function saveUndo(path: string, entry: UndoEntry): void {
|
|
12
|
+
undoMap.set(path, entry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getUndo(path: string): UndoEntry | undefined {
|
|
16
|
+
return undoMap.get(path);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function clearUndo(path: string): void {
|
|
20
|
+
undoMap.delete(path);
|
|
21
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -17,16 +17,30 @@ export function cntLines(text: string): number {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function rejectUnknownFields(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
obj: Record<string, unknown>,
|
|
21
|
+
allowed: Set<string>,
|
|
22
|
+
label: string,
|
|
23
|
+
hint?: string,
|
|
24
24
|
): void {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
const unknown = Object.keys(obj).filter((key) => !allowed.has(key));
|
|
26
|
+
if (unknown.length > 0) {
|
|
27
|
+
const suffix = hint ? ` ${hint}` : "";
|
|
28
|
+
throw new Error(
|
|
29
|
+
`[E_BAD_SHAPE] ${label} contains unknown or unsupported fields: ${unknown.join(", ")}.${suffix}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cntDiff(diff: string, marker: "+" | "-"): number {
|
|
35
|
+
if (!diff) return 0;
|
|
36
|
+
let count = 0;
|
|
37
|
+
for (const line of diff.split("\n")) {
|
|
38
|
+
if (
|
|
39
|
+
line.startsWith(marker) &&
|
|
40
|
+
!line.startsWith(`${marker}${marker}${marker}`)
|
|
41
|
+
) {
|
|
42
|
+
count += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return count;
|
|
32
46
|
}
|