pi-hashline-edit-pro 0.16.0 → 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 +6 -13
- package/package.json +1 -1
- package/prompts/replace-bulk-guidelines.md +3 -2
- package/prompts/replace-bulk.md +1 -1
- package/prompts/replace-flat-guidelines.md +3 -2
- package/prompts/replace-flat.md +1 -1
- package/prompts/replace-guidelines.md +4 -2
- package/prompts/replace.md +1 -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/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 +14 -18
- package/src/replace-response.ts +1 -14
- package/src/replace-undo.ts +6 -24
- package/src/replace.ts +5 -29
- package/src/undo-store.ts +0 -14
- 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
|
@@ -20,12 +20,9 @@ import { loadHashStore, pruneHashStore } from "./src/hash-store";
|
|
|
20
20
|
export default function (pi: ExtensionAPI): void {
|
|
21
21
|
regRead(pi);
|
|
22
22
|
|
|
23
|
-
// Register the bulk-mode replace tool by default. The session_start handler
|
|
24
|
-
// will re-register with the correct mode from the persisted config.
|
|
25
23
|
regReplace(pi);
|
|
26
24
|
regReplaceUndo(pi);
|
|
27
25
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
28
|
-
// Initial auto-read from env var; session_start overrides with persisted value
|
|
29
26
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
30
27
|
let autoRead = autoReadValue === "1" || autoReadValue === "true";
|
|
31
28
|
|
|
@@ -33,12 +30,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
33
30
|
const active = pi.getActiveTools();
|
|
34
31
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
35
32
|
await initHasher();
|
|
36
|
-
// Prune stale snapshots (files that no longer exist) on session start
|
|
37
33
|
try {
|
|
38
34
|
const store = await loadHashStore();
|
|
39
35
|
await pruneHashStore(store);
|
|
40
|
-
} catch {
|
|
41
|
-
// Re-register the replace tool according to the persisted mode
|
|
36
|
+
} catch {}
|
|
42
37
|
const mode = await readReplaceMode();
|
|
43
38
|
if (mode === "flat") {
|
|
44
39
|
regReplaceFlat(pi);
|
|
@@ -46,7 +41,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
46
41
|
regReplace(pi);
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
// Read the persisted auto-read setting (overrides env var default)
|
|
50
44
|
autoRead = await readAutoRead();
|
|
51
45
|
|
|
52
46
|
if (debugValue === "1" || debugValue === "true") {
|
|
@@ -58,7 +52,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
58
52
|
description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
|
|
59
53
|
handler: async (_args, ctx) => {
|
|
60
54
|
const mode = await toggleReplaceMode();
|
|
61
|
-
// Re-register the tool with the new mode
|
|
62
55
|
if (mode === "flat") {
|
|
63
56
|
regReplaceFlat(pi);
|
|
64
57
|
} else {
|
|
@@ -69,10 +62,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
69
62
|
});
|
|
70
63
|
|
|
71
64
|
pi.registerCommand("toggle-auto-read", {
|
|
72
|
-
description: "Toggle automatic hashline anchors after write operations",
|
|
65
|
+
description: "Toggle automatic hashline anchors after write and replace operations",
|
|
73
66
|
handler: async (_args, ctx) => {
|
|
74
67
|
autoRead = await toggleAutoRead();
|
|
75
|
-
// Re-register the tool so prompts reflect the new auto-read setting
|
|
76
68
|
const mode = await readReplaceMode();
|
|
77
69
|
if (mode === "flat") {
|
|
78
70
|
regReplaceFlat(pi);
|
|
@@ -80,13 +72,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
80
72
|
regReplace(pi);
|
|
81
73
|
}
|
|
82
74
|
const state = autoRead ? "enabled" : "disabled";
|
|
83
|
-
ctx.ui.notify(`Auto-read after write: ${state}`, "info");
|
|
75
|
+
ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
|
|
84
76
|
},
|
|
85
77
|
});
|
|
86
78
|
|
|
87
79
|
pi.on("tool_result", async (event, ctx) => {
|
|
88
80
|
if (!autoRead) return;
|
|
89
|
-
if (event.
|
|
81
|
+
if (event.isError) return;
|
|
82
|
+
if (event.toolName !== "write" && event.toolName !== "replace") return;
|
|
90
83
|
|
|
91
84
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
92
85
|
if (typeof filePath !== "string") return;
|
|
@@ -108,7 +101,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
101
|
],
|
|
109
102
|
};
|
|
110
103
|
} catch (error) {
|
|
111
|
-
console.error("Auto-read after write failed:", error);
|
|
104
|
+
console.error("Auto-read after write/replace failed:", error);
|
|
112
105
|
}
|
|
113
106
|
});
|
|
114
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
|
@@ -128,4 +128,4 @@ Error recovery:
|
|
|
128
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.)
|
|
129
129
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
130
130
|
|
|
131
|
-
**Undo:** If a replace produced incorrect results, call `
|
|
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
|
@@ -114,4 +114,4 @@ Error recovery:
|
|
|
114
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.)
|
|
115
115
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
116
116
|
|
|
117
|
-
**Undo:** If a replace produced incorrect results, call `
|
|
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
|
@@ -178,4 +178,4 @@ Error recovery:
|
|
|
178
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.)
|
|
179
179
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
180
180
|
|
|
181
|
-
**Undo:** If a replace produced incorrect results, call `
|
|
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/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,12 +9,6 @@ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | und
|
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Coerces an array of edit items: JSON-string items → objects,
|
|
14
|
-
* JSON-string content_lines → string arrays. Shared by the `changes`
|
|
15
|
-
* and `edits` normalization branches.
|
|
16
|
-
* Returns a warning if any item was a JSON string.
|
|
17
|
-
*/
|
|
18
12
|
function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
|
|
19
13
|
const warnings: string[] = [];
|
|
20
14
|
const result = items
|
|
@@ -43,12 +37,6 @@ function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: strin
|
|
|
43
37
|
return { result, warnings };
|
|
44
38
|
}
|
|
45
39
|
|
|
46
|
-
/**
|
|
47
|
-
* Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
|
|
48
|
-
* single objects → wrapped in array. Shared by the `changes` and `edits`
|
|
49
|
-
* normalization branches.
|
|
50
|
-
* Returns a warning if the field was a JSON string.
|
|
51
|
-
*/
|
|
52
40
|
function normalizeField(
|
|
53
41
|
record: Record<string, unknown>,
|
|
54
42
|
from: string,
|
|
@@ -85,6 +73,19 @@ function normalizeField(
|
|
|
85
73
|
return undefined;
|
|
86
74
|
}
|
|
87
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
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
88
89
|
export function normReq(input: unknown): unknown {
|
|
89
90
|
if (!isRec(input)) {
|
|
90
91
|
return input;
|
|
@@ -93,18 +94,13 @@ export function normReq(input: unknown): unknown {
|
|
|
93
94
|
const record: Record<string, unknown> = { ...input };
|
|
94
95
|
const warnings: string[] = [];
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
record.path = record.file_path;
|
|
98
|
-
delete record.file_path;
|
|
99
|
-
}
|
|
97
|
+
normalizeFilePath(record);
|
|
100
98
|
|
|
101
99
|
const w1 = normalizeField(record, "changes", "changes");
|
|
102
100
|
if (w1) warnings.push(w1);
|
|
103
101
|
const w2 = normalizeField(record, "edits", "changes");
|
|
104
102
|
if (w2) warnings.push(w2);
|
|
105
103
|
|
|
106
|
-
// Handle flat format: hash_range_inclusive and content_lines at top level
|
|
107
|
-
// (no changes array). Wrap them into a single-element changes array.
|
|
108
104
|
if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
|
|
109
105
|
const hriRaw = record.hash_range_inclusive;
|
|
110
106
|
const hri = tryParseJSON(hriRaw, (v): v is string[] =>
|
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";
|
package/src/replace-undo.ts
CHANGED
|
@@ -7,29 +7,17 @@ import { loadHashStore, saveHashStore } from "./hash-store";
|
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./path-utils";
|
|
9
9
|
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
10
|
+
import { cntDiff } from "./utils";
|
|
11
|
+
import { loadP, loadGuide } from "./prompts";
|
|
10
12
|
|
|
11
|
-
function cntDiff(diff: string, marker: "+" | "-"): number {
|
|
12
|
-
if (!diff) return 0;
|
|
13
|
-
let count = 0;
|
|
14
|
-
for (const line of diff.split("\n")) {
|
|
15
|
-
if (
|
|
16
|
-
line.startsWith(marker) &&
|
|
17
|
-
!line.startsWith(`${marker}${marker}${marker}`)
|
|
18
|
-
) {
|
|
19
|
-
count += 1;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return count;
|
|
23
|
-
}
|
|
24
13
|
|
|
25
14
|
export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
26
15
|
pi.registerTool({
|
|
27
|
-
name: "
|
|
16
|
+
name: "undo_last_replace",
|
|
28
17
|
label: "Undo Last Replace",
|
|
29
|
-
description:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"After undoing, call `read` to get fresh anchors for a corrected 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"),
|
|
33
21
|
parameters: Type.Object({
|
|
34
22
|
path: Type.String({
|
|
35
23
|
description: "Path to the file to undo the last replace on",
|
|
@@ -56,29 +44,24 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
56
44
|
}
|
|
57
45
|
|
|
58
46
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
59
|
-
// Read current file content to compute diff against the pre-replace state
|
|
60
47
|
let currentNormalized = "";
|
|
61
48
|
try {
|
|
62
49
|
const currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
63
50
|
const { text: currentStripped } = stripBOM(currentRaw);
|
|
64
51
|
currentNormalized = toLF(currentStripped);
|
|
65
52
|
} catch {
|
|
66
|
-
// File may have been deleted; treat as empty
|
|
67
53
|
currentNormalized = "";
|
|
68
54
|
}
|
|
69
55
|
|
|
70
|
-
// Compute diff: old = pre-replace (undo.content), new = current
|
|
71
56
|
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
|
72
57
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
73
58
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
74
59
|
|
|
75
|
-
// Write the pre-edit content back to the file
|
|
76
60
|
await writeAtomic(
|
|
77
61
|
mutationTargetPath,
|
|
78
62
|
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
79
63
|
);
|
|
80
64
|
|
|
81
|
-
// Restore the hash store snapshot so the next read returns the same hashes
|
|
82
65
|
const store = await loadHashStore();
|
|
83
66
|
store.snapshots[mutationTargetPath] = {
|
|
84
67
|
content: undo.content,
|
|
@@ -86,7 +69,6 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
86
69
|
};
|
|
87
70
|
await saveHashStore(store);
|
|
88
71
|
|
|
89
|
-
// Clear undo so a second call without an intervening replace is a no-op
|
|
90
72
|
clearUndo(mutationTargetPath);
|
|
91
73
|
|
|
92
74
|
const parts: string[] = [
|
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";
|
|
@@ -76,11 +76,6 @@ export const editToolSchema = Type.Object(
|
|
|
76
76
|
{ additionalProperties: false },
|
|
77
77
|
);
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
* Flat-mode schema: hash_range_inclusive and content_lines are at the top
|
|
81
|
-
* level instead of inside a "changes" array. Only a single edit is supported
|
|
82
|
-
* per call (no bulk changes).
|
|
83
|
-
*/
|
|
84
79
|
export const flatEditToolSchema = Type.Object(
|
|
85
80
|
{
|
|
86
81
|
path: Type.String({ description: "path" }),
|
|
@@ -181,12 +176,6 @@ export async function execPipeline(
|
|
|
181
176
|
|
|
182
177
|
const result = anchorResult.content;
|
|
183
178
|
|
|
184
|
-
// Collect hashes targeted by the edit for hash-aware diff disambiguation.
|
|
185
|
-
// Include every hash in the replaced range, not just the boundary anchors,
|
|
186
|
-
// so that a surviving line whose content is identical to an interior line
|
|
187
|
-
// of the edit is not matched to a removed hash.
|
|
188
|
-
// Use originalHashes.indexOf to find line numbers since resEdits returns
|
|
189
|
-
// HEdit[] (Anchor = { hash }) not RHEdit[] (RAnchor = { line, hash }).
|
|
190
179
|
const removedHashes = new Set<string>();
|
|
191
180
|
for (const edit of resolved) {
|
|
192
181
|
const startHash = edit.hash_range_inclusive[0].hash;
|
|
@@ -200,14 +189,12 @@ export async function execPipeline(
|
|
|
200
189
|
}
|
|
201
190
|
}
|
|
202
191
|
|
|
203
|
-
// Compute stable result hashes using hash-aware preservation
|
|
204
192
|
const resultHashes = await lineHashes(result, resolvedPath, {
|
|
205
193
|
content: originalNormalized,
|
|
206
194
|
hashes: originalHashes,
|
|
207
195
|
removedHashes,
|
|
208
196
|
});
|
|
209
197
|
|
|
210
|
-
// Format boundary warnings using stable result hashes
|
|
211
198
|
const resultLines = result.split("\n");
|
|
212
199
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
213
200
|
for (const bw of anchorResult.boundaryWarnings ?? []) {
|
|
@@ -299,7 +286,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
299
286
|
export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
300
287
|
const autoRead = readAutoReadSync();
|
|
301
288
|
const readGuidance = autoRead
|
|
302
|
-
? "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."
|
|
303
290
|
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
304
291
|
|
|
305
292
|
const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
|
|
@@ -321,20 +308,11 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
321
308
|
promptGuidelines: E_GUIDE,
|
|
322
309
|
prepareArguments: opts.flat
|
|
323
310
|
? (args: unknown) => {
|
|
324
|
-
// Minimal normalization: file_path → path, JSON string parsing.
|
|
325
|
-
// The flat-to-canonical conversion happens in execute().
|
|
326
311
|
if (!isRec(args)) return args as any;
|
|
327
312
|
const record = { ...args };
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
if (typeof record.hash_range_inclusive === "string") {
|
|
333
|
-
try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
|
|
334
|
-
}
|
|
335
|
-
if (typeof record.content_lines === "string") {
|
|
336
|
-
try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
|
|
337
|
-
}
|
|
313
|
+
normalizeFilePath(record);
|
|
314
|
+
tryParseField(record, "hash_range_inclusive");
|
|
315
|
+
tryParseField(record, "content_lines");
|
|
338
316
|
return record as any;
|
|
339
317
|
}
|
|
340
318
|
: (args: unknown) =>
|
|
@@ -428,8 +406,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
428
406
|
},
|
|
429
407
|
|
|
430
408
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
431
|
-
// Flat mode: wrap top-level fields into a single-element changes array.
|
|
432
|
-
// Bulk mode: use params as-is after normReq normalization.
|
|
433
409
|
const canonical = opts.flat
|
|
434
410
|
? normReq({
|
|
435
411
|
path: (params as any).path,
|
package/src/undo-store.ts
CHANGED
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory undo store for the last_replace_undo tool.
|
|
3
|
-
*
|
|
4
|
-
* Before each successful replace, the pre-edit file state (normalized content,
|
|
5
|
-
* BOM, original line ending, and hashes) is saved keyed by absolute path.
|
|
6
|
-
* The undo tool reads this entry, restores the file, and clears the entry.
|
|
7
|
-
*
|
|
8
|
-
* Only the most recent replace per file is tracked — calling undo twice
|
|
9
|
-
* without an intervening replace will produce "no undo history".
|
|
10
|
-
*/
|
|
11
1
|
|
|
12
2
|
export interface UndoEntry {
|
|
13
|
-
/** Normalized (LF) content before the edit */
|
|
14
3
|
content: string;
|
|
15
|
-
/** BOM prefix that was stripped from the original file */
|
|
16
4
|
bom: string;
|
|
17
|
-
/** Original line ending detected before the edit */
|
|
18
5
|
originalEnding: "\r\n" | "\n";
|
|
19
|
-
/** Hash array for the pre-edit content */
|
|
20
6
|
hashes: string[];
|
|
21
7
|
}
|
|
22
8
|
|
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
|
}
|