pi-hashline-edit-pro 0.16.11 → 0.16.13
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/index.ts +20 -32
- package/package.json +1 -1
- package/prompts/read.md +2 -0
- package/prompts/replace-guidelines.md +6 -0
- package/prompts/replace-snippet.md +1 -0
- package/prompts/replace.md +33 -0
- package/src/config.ts +9 -50
- package/src/file-reader.ts +28 -5
- package/src/fs-write.ts +1 -1
- package/src/hash-store.ts +1 -1
- package/src/hashline/apply.ts +26 -10
- package/src/hashline/hash.ts +22 -23
- package/src/hashline/resolve.ts +30 -6
- package/src/paths.ts +12 -1
- package/src/read.ts +3 -3
- package/src/replace-normalize.ts +27 -9
- package/src/replace-undo.ts +21 -2
- package/src/replace.ts +74 -19
- package/src/utils.ts +11 -3
- package/src/validation.ts +1 -9
- package/prompts/replace-bulk-guidelines.md +0 -6
- package/prompts/replace-bulk-snippet.md +0 -1
- package/prompts/replace-bulk.md +0 -105
- package/prompts/replace-flat-guidelines.md +0 -6
- package/prompts/replace-flat-snippet.md +0 -1
- package/prompts/replace-flat.md +0 -95
- package/src/path-utils.ts +0 -13
- package/src/replace-flat.ts +0 -17
- package/src/runtime.ts +0 -3
- package/src/snapshot.ts +0 -22
- package/src/undo-store.ts +0 -21
package/index.ts
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFile } from "fs/promises";
|
|
3
|
-
import { join, isAbsolute } from "path";
|
|
4
2
|
import { initHasher } from "./src/hashline";
|
|
5
|
-
import { regReplace } from "./src/replace";
|
|
6
|
-
import { regReplaceFlat } from "./src/replace-flat";
|
|
3
|
+
import { regReplace, regReplaceFlat } from "./src/replace";
|
|
7
4
|
import { regReplaceUndo } from "./src/replace-undo";
|
|
8
5
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
9
|
-
import { toLF, stripBOM } from "./src/replace-diff";
|
|
10
|
-
import { resolveTarget } from "./src/fs-write";
|
|
11
6
|
import { visLines } from "./src/utils";
|
|
12
7
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
13
8
|
import {
|
|
14
|
-
|
|
9
|
+
readConfig,
|
|
15
10
|
toggleReplaceMode,
|
|
16
|
-
readAutoRead,
|
|
17
11
|
toggleAutoRead,
|
|
18
12
|
} from "./src/config";
|
|
19
13
|
import { loadHashStore, pruneHashStore } from "./src/hash-store";
|
|
14
|
+
import { readNormFile } from "./src/file-reader";
|
|
20
15
|
|
|
21
16
|
export default function (pi: ExtensionAPI): void {
|
|
22
17
|
regRead(pi);
|
|
23
18
|
|
|
24
19
|
regReplace(pi);
|
|
25
20
|
regReplaceUndo(pi);
|
|
21
|
+
|
|
22
|
+
function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
|
|
23
|
+
if (mode === "flat") {
|
|
24
|
+
regReplaceFlat(pi, autoRead);
|
|
25
|
+
} else {
|
|
26
|
+
regReplace(pi, autoRead);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
26
29
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
27
30
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
28
31
|
let autoRead = autoReadValue === "1" || autoReadValue === "true";
|
|
@@ -37,14 +40,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
37
40
|
} catch (err) {
|
|
38
41
|
console.error("Failed to load or prune hash store:", err);
|
|
39
42
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
regReplace(pi);
|
|
45
|
-
}
|
|
43
|
+
const config = await readConfig();
|
|
44
|
+
const mode = config.replaceMode;
|
|
45
|
+
autoRead = config.autoRead;
|
|
46
|
+
registerReplaceTool(pi, mode, autoRead);
|
|
46
47
|
|
|
47
|
-
autoRead = await readAutoRead();
|
|
48
48
|
|
|
49
49
|
if (debugValue === "1" || debugValue === "true") {
|
|
50
50
|
ctx.ui.notify(`Hashline Edit mode active (${mode} replace)`, "info");
|
|
@@ -55,11 +55,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
55
55
|
description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
|
|
56
56
|
handler: async (_args, ctx) => {
|
|
57
57
|
const mode = await toggleReplaceMode();
|
|
58
|
-
|
|
59
|
-
regReplaceFlat(pi);
|
|
60
|
-
} else {
|
|
61
|
-
regReplace(pi);
|
|
62
|
-
}
|
|
58
|
+
registerReplaceTool(pi, mode, autoRead);
|
|
63
59
|
ctx.ui.notify(`Replace mode switched to: ${mode}`, "info");
|
|
64
60
|
},
|
|
65
61
|
});
|
|
@@ -68,12 +64,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
64
|
description: "Toggle automatic hashline anchors after write and replace operations",
|
|
69
65
|
handler: async (_args, ctx) => {
|
|
70
66
|
autoRead = await toggleAutoRead();
|
|
71
|
-
const mode = await
|
|
72
|
-
|
|
73
|
-
regReplaceFlat(pi);
|
|
74
|
-
} else {
|
|
75
|
-
regReplace(pi);
|
|
76
|
-
}
|
|
67
|
+
const mode = (await readConfig()).replaceMode;
|
|
68
|
+
registerReplaceTool(pi, mode, autoRead);
|
|
77
69
|
const state = autoRead ? "enabled" : "disabled";
|
|
78
70
|
ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
|
|
79
71
|
},
|
|
@@ -88,15 +80,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
88
80
|
if (typeof filePath !== "string") return;
|
|
89
81
|
|
|
90
82
|
try {
|
|
91
|
-
const absolutePath =
|
|
92
|
-
const resolvedPath = await resolveTarget(absolutePath);
|
|
93
|
-
const content = await readFile(resolvedPath, "utf-8");
|
|
94
|
-
const { text: rawContent } = stripBOM(content);
|
|
95
|
-
const normalized = toLF(rawContent);
|
|
83
|
+
const { normalized, fileHashes, absolutePath } = await readNormFile(filePath, ctx.cwd, undefined);
|
|
96
84
|
|
|
97
85
|
if (visLines(normalized).length === 0) return;
|
|
98
86
|
|
|
99
|
-
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX },
|
|
87
|
+
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
|
|
100
88
|
|
|
101
89
|
return {
|
|
102
90
|
content: [
|
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Read a text file. Each line is returned as `HASH│content`.
|
|
2
2
|
|
|
3
|
+
Key rule: line numbers are NOT part of the output. Use the 3-char HASH to reference lines in replace calls. A HASH may change when the line content changes.
|
|
4
|
+
|
|
3
5
|
HASH format:
|
|
4
6
|
- The HASH is 3 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3`, `4yN`, `-qk`).
|
|
5
7
|
- The content after the `│` separator is the line verbatim.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{{MODE_PREFIX}}
|
|
2
|
+
- After a successful replace, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
3
|
+
- On [E_STALE_ANCHOR], call read to get fresh anchors, copy the 3-character HASH of the start and end of the range into hash_range_inclusive, and retry.
|
|
4
|
+
- hash_range_inclusive replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put replacement lines in content_lines — do not include lines that already exist outside the range.
|
|
5
|
+
- Preserve leading whitespace exactly. The content after │ in read output includes all leading spaces and tabs — copy them into content_lines unchanged.
|
|
6
|
+
- content_lines must be a native JSON array of strings, not a JSON string.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Replace lines in a text file via HASH anchors from read, {{MODE_PREFIX}}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Replace lines in a text file using HASH anchors from `read`.{{MODE_DESCRIPTION}}
|
|
2
|
+
|
|
3
|
+
Workflow:
|
|
4
|
+
1. Call `read` to get `HASH│content` lines for the file.
|
|
5
|
+
2. Identify the range to replace using the 3-char hashes from the read output.
|
|
6
|
+
3. Call `replace` with `hash_range_inclusive` (the two hashes) and `content_lines` (the new lines).
|
|
7
|
+
4. On `[E_STALE_ANCHOR]`, call `read` again for fresh anchors, then retry.
|
|
8
|
+
5. On success, call `read` to get fresh anchors for follow-up edits.
|
|
9
|
+
|
|
10
|
+
Request structure:
|
|
11
|
+
{{MODE_REQUEST_STRUCTURE}}
|
|
12
|
+
|
|
13
|
+
Fields:
|
|
14
|
+
- content_lines — replacement lines as a JSON array of strings. File content only — no HASH│ prefix.
|
|
15
|
+
- hash_range_inclusive — [start_hash, end_hash] from read output. 3-char base64 hash only — no │ or line content.
|
|
16
|
+
- path — file to edit.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
{{MODE_EXAMPLES}}
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
- Anchors must be 3-char base64 hashes from the most recent read. Stale anchors fail with [E_STALE_ANCHOR].
|
|
23
|
+
- The range is inclusive: every line from start_hash through end_hash is deleted.
|
|
24
|
+
- Those lines are replaced with content_lines — nothing is inserted, nothing is appended.
|
|
25
|
+
- content_lines is literal file content. Never include the HASH│ prefix — that goes in hash_range_inclusive.
|
|
26
|
+
- content_lines must be a native JSON array of strings, not a serialized string.
|
|
27
|
+
- Preserve leading whitespace exactly as it appears after │ in read output.
|
|
28
|
+
- To delete lines, use content_lines: [].
|
|
29
|
+
- If content_lines matches current content, the edit is a noop (file unchanged).
|
|
30
|
+
{{MODE_RULES}}
|
|
31
|
+
On success, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
32
|
+
|
|
33
|
+
Recovery: If a replace produces incorrect results, call undo_last_replace with the file path to revert.
|
package/src/config.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from "fs";
|
|
2
1
|
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
3
2
|
import { configDir, configPath } from "./paths";
|
|
4
|
-
import { errCode } from "./
|
|
3
|
+
import { errCode } from "./utils";
|
|
5
4
|
|
|
6
5
|
export type ReplaceMode = "bulk" | "flat";
|
|
7
|
-
|
|
8
6
|
export interface Config {
|
|
9
7
|
replaceMode: ReplaceMode;
|
|
10
8
|
autoRead: boolean;
|
|
@@ -15,14 +13,17 @@ const DEFAULT_CONFIG: Config = {
|
|
|
15
13
|
autoRead: false
|
|
16
14
|
};
|
|
17
15
|
|
|
16
|
+
function parseConfig(content: string): Config {
|
|
17
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
18
|
+
return {
|
|
19
|
+
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
20
|
+
autoRead: parsed.autoRead === true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
18
23
|
export async function readConfig(): Promise<Config> {
|
|
19
24
|
try {
|
|
20
25
|
const content = await readFile(configPath(), "utf-8");
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
24
|
-
autoRead: parsed.autoRead === true,
|
|
25
|
-
};
|
|
26
|
+
return parseConfig(content);
|
|
26
27
|
} catch (error: unknown) {
|
|
27
28
|
if (errCode(error) !== "ENOENT") {
|
|
28
29
|
console.error("Config file corrupted, using defaults:", error);
|
|
@@ -30,38 +31,11 @@ export async function readConfig(): Promise<Config> {
|
|
|
30
31
|
return { ...DEFAULT_CONFIG };
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
export function readConfigSync(): Config {
|
|
35
|
-
try {
|
|
36
|
-
const content = readFileSync(configPath(), "utf-8");
|
|
37
|
-
const parsed = JSON.parse(content) as Partial<Config>;
|
|
38
|
-
return {
|
|
39
|
-
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
40
|
-
autoRead: parsed.autoRead === true,
|
|
41
|
-
};
|
|
42
|
-
} catch (error: unknown) {
|
|
43
|
-
if (errCode(error) !== "ENOENT") {
|
|
44
|
-
console.error("Config file corrupted, using defaults:", error);
|
|
45
|
-
}
|
|
46
|
-
return { ...DEFAULT_CONFIG };
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
34
|
export async function writeConfig(config: Config): Promise<void> {
|
|
51
35
|
await mkdir(configDir(), { recursive: true });
|
|
52
36
|
await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
53
37
|
}
|
|
54
38
|
|
|
55
|
-
export async function readReplaceMode(): Promise<ReplaceMode> {
|
|
56
|
-
const config = await readConfig();
|
|
57
|
-
return config.replaceMode;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function writeReplaceMode(mode: ReplaceMode): Promise<void> {
|
|
61
|
-
const config = await readConfig();
|
|
62
|
-
config.replaceMode = mode;
|
|
63
|
-
await writeConfig(config);
|
|
64
|
-
}
|
|
65
39
|
|
|
66
40
|
export async function toggleReplaceMode(): Promise<ReplaceMode> {
|
|
67
41
|
const config = await readConfig();
|
|
@@ -70,21 +44,6 @@ export async function toggleReplaceMode(): Promise<ReplaceMode> {
|
|
|
70
44
|
return config.replaceMode;
|
|
71
45
|
}
|
|
72
46
|
|
|
73
|
-
export async function readAutoRead(): Promise<boolean> {
|
|
74
|
-
const config = await readConfig();
|
|
75
|
-
return config.autoRead;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function readAutoReadSync(): boolean {
|
|
79
|
-
const config = readConfigSync();
|
|
80
|
-
return config.autoRead;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function writeAutoRead(value: boolean): Promise<void> {
|
|
84
|
-
const config = await readConfig();
|
|
85
|
-
config.autoRead = value;
|
|
86
|
-
await writeConfig(config);
|
|
87
|
-
}
|
|
88
47
|
|
|
89
48
|
export async function toggleAutoRead(): Promise<boolean> {
|
|
90
49
|
const config = await readConfig();
|
package/src/file-reader.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
|
+
import { stat } from "fs/promises";
|
|
2
3
|
import { lineHashes } from "./hashline";
|
|
3
4
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
4
5
|
import { resolveTarget } from "./fs-write";
|
|
5
|
-
import { toCwd } from "./
|
|
6
|
+
import { toCwd } from "./paths";
|
|
6
7
|
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
7
|
-
import { abortIf } from "./
|
|
8
|
-
import {
|
|
8
|
+
import { abortIf } from "./utils";
|
|
9
|
+
import { valKind, valAccess } from "./validation";
|
|
9
10
|
import { visLines } from "./utils";
|
|
11
|
+
import type { HashStore } from "./hash-store";
|
|
10
12
|
export interface NormFile {
|
|
11
13
|
absolutePath: string;
|
|
12
14
|
normalized: string;
|
|
@@ -16,6 +18,26 @@ export interface NormFile {
|
|
|
16
18
|
hadUtf8DecodeErrors: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
export type SnapInfo = {
|
|
22
|
+
snapshotId: string;
|
|
23
|
+
mtimeMs: number;
|
|
24
|
+
size: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function fmtSnapId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
|
|
28
|
+
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
|
|
32
|
+
const canonicalPath = await resolveTarget(absolutePath);
|
|
33
|
+
const stats = await stat(canonicalPath);
|
|
34
|
+
return {
|
|
35
|
+
snapshotId: fmtSnapId(canonicalPath, stats),
|
|
36
|
+
mtimeMs: stats.mtimeMs,
|
|
37
|
+
size: stats.size,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
export async function readNormFile(
|
|
20
42
|
path: string,
|
|
21
43
|
cwd: string,
|
|
@@ -23,6 +45,7 @@ export async function readNormFile(
|
|
|
23
45
|
accessMode: number = constants.R_OK,
|
|
24
46
|
preloadedFile?: LFile,
|
|
25
47
|
maxLines?: number,
|
|
48
|
+
store?: HashStore,
|
|
26
49
|
): Promise<NormFile> {
|
|
27
50
|
const absolutePath = toCwd(path, cwd);
|
|
28
51
|
const resolvedPath = await resolveTarget(absolutePath);
|
|
@@ -32,7 +55,7 @@ export async function readNormFile(
|
|
|
32
55
|
|
|
33
56
|
abortIf(signal);
|
|
34
57
|
const file = preloadedFile ?? (await loadFileKindAndText(resolvedPath));
|
|
35
|
-
|
|
58
|
+
valKind(file, path);
|
|
36
59
|
|
|
37
60
|
abortIf(signal);
|
|
38
61
|
const { bom, text: rawContent } = stripBOM(file.text);
|
|
@@ -48,7 +71,7 @@ export async function readNormFile(
|
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
|
|
51
|
-
const fileHashes = await lineHashes(normalized, resolvedPath);
|
|
74
|
+
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, store);
|
|
52
75
|
return {
|
|
53
76
|
absolutePath: resolvedPath,
|
|
54
77
|
normalized,
|
package/src/fs-write.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
copyFile,
|
|
12
12
|
} from "fs/promises";
|
|
13
13
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
14
|
-
import { errCode } from "./
|
|
14
|
+
import { errCode } from "./utils";
|
|
15
15
|
|
|
16
16
|
export async function resolveTarget(path: string): Promise<string> {
|
|
17
17
|
const absolutePath = resolve(path);
|
package/src/hash-store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, mkdir } from "fs/promises";
|
|
2
2
|
import { stat } from "fs/promises";
|
|
3
3
|
import { hashStorePath, hashStoreDir } from "./paths";
|
|
4
|
-
import { errCode } from "./
|
|
4
|
+
import { errCode } from "./utils";
|
|
5
5
|
import { writeAtomic } from "./fs-write";
|
|
6
6
|
function isValidSnapshot(value: unknown): value is FileSnapshot {
|
|
7
7
|
if (typeof value !== "object" || value === null) return false;
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf } from "../
|
|
1
|
+
import { abortIf } from "../utils";
|
|
2
2
|
import { _lineHashesPure, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdits,
|
|
@@ -12,7 +12,21 @@ import {
|
|
|
12
12
|
type BDupWarn,
|
|
13
13
|
type AutoFix,
|
|
14
14
|
} from "./resolve";
|
|
15
|
-
import {
|
|
15
|
+
import { visLines } from "../utils";
|
|
16
|
+
|
|
17
|
+
function lastNonEmptyIndex(lines: string[]): number {
|
|
18
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
19
|
+
if (lines[i]!.length > 0) return i;
|
|
20
|
+
}
|
|
21
|
+
return -1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function firstNonEmptyIndex(lines: string[]): number {
|
|
25
|
+
for (let i = 0; i < lines.length; i++) {
|
|
26
|
+
if (lines[i]!.length > 0) return i;
|
|
27
|
+
}
|
|
28
|
+
return -1;
|
|
29
|
+
}
|
|
16
30
|
|
|
17
31
|
type LIdx = {
|
|
18
32
|
fileLines: string[];
|
|
@@ -306,13 +320,15 @@ export function applyEdits(
|
|
|
306
320
|
const edit = correctedEdits[bw.editIndex];
|
|
307
321
|
if (!edit) continue;
|
|
308
322
|
if (bw.kind === "trailing") {
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
323
|
+
const idx = lastNonEmptyIndex(edit.content_lines);
|
|
324
|
+
if (idx >= 0) {
|
|
325
|
+
const removed = edit.content_lines.splice(idx, 1)[0];
|
|
311
326
|
autoFixes.push({ kind: "trailing", editIndex: bw.editIndex, removedLine: removed });
|
|
312
327
|
}
|
|
313
328
|
} else {
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
329
|
+
const idx = firstNonEmptyIndex(edit.content_lines);
|
|
330
|
+
if (idx >= 0) {
|
|
331
|
+
const removed = edit.content_lines.splice(idx, 1)[0];
|
|
316
332
|
autoFixes.push({ kind: "leading", editIndex: bw.editIndex, removedLine: removed });
|
|
317
333
|
}
|
|
318
334
|
}
|
|
@@ -377,14 +393,14 @@ export function changedRange(
|
|
|
377
393
|
if (original.length === 0) {
|
|
378
394
|
return {
|
|
379
395
|
firstChangedLine: 1,
|
|
380
|
-
lastChangedLine:
|
|
396
|
+
lastChangedLine: visLines(result).length,
|
|
381
397
|
};
|
|
382
398
|
}
|
|
383
399
|
|
|
384
400
|
if (result.startsWith(original) && original.endsWith("\n")) {
|
|
385
401
|
return {
|
|
386
|
-
firstChangedLine:
|
|
387
|
-
lastChangedLine:
|
|
402
|
+
firstChangedLine: visLines(original).length + 1,
|
|
403
|
+
lastChangedLine: visLines(result).length,
|
|
388
404
|
};
|
|
389
405
|
}
|
|
390
406
|
|
|
@@ -417,7 +433,7 @@ export function changedRange(
|
|
|
417
433
|
const firstChangedLine = idxToLine(firstDiff + 1, result);
|
|
418
434
|
let lastChangedLine: number;
|
|
419
435
|
if (lastRes < firstDiff) {
|
|
420
|
-
lastChangedLine = result.length === 0 ? 1 :
|
|
436
|
+
lastChangedLine = result.length === 0 ? 1 : visLines(result).length;
|
|
421
437
|
} else if (
|
|
422
438
|
firstDiff === 0 &&
|
|
423
439
|
original.length > 0 &&
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import xxhash from "xxhash-wasm";
|
|
2
2
|
import * as Diff from "diff";
|
|
3
|
-
import { loadHashStore, saveHashStore } from "../hash-store";
|
|
3
|
+
import { loadHashStore, saveHashStore, type HashStore } from "../hash-store";
|
|
4
4
|
import { MAX_HASH_RETRIES } from "../constants";
|
|
5
5
|
|
|
6
6
|
export const HASH_LEN = 3;
|
|
@@ -70,20 +70,25 @@ function canon(line: string): string {
|
|
|
70
70
|
return line.replace(/\r/g, "").trimEnd();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function nextUniqueHash(content: string, used: Set<string>): string {
|
|
74
|
+
let retry = 0;
|
|
75
|
+
let hash = h2s(xxh32(content));
|
|
76
|
+
while (used.has(hash)) {
|
|
77
|
+
retry++;
|
|
78
|
+
if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
|
|
79
|
+
hash = h2s(xxh32(`${content}:R${retry}`));
|
|
80
|
+
}
|
|
81
|
+
used.add(hash);
|
|
82
|
+
return hash;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
export function _lineHashesPure(content: string): string[] {
|
|
74
86
|
const lines = content.split("\n");
|
|
75
87
|
const hashes = new Array<string>(lines.length);
|
|
76
88
|
const assigned = new Set<string>();
|
|
77
89
|
for (let i = 0; i < lines.length; i++) {
|
|
78
90
|
const c = canon(lines[i]!);
|
|
79
|
-
|
|
80
|
-
let retry = 0;
|
|
81
|
-
while (assigned.has(hash)) {
|
|
82
|
-
retry++;
|
|
83
|
-
if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
|
|
84
|
-
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
85
|
-
}
|
|
86
|
-
assigned.add(hash);
|
|
91
|
+
const hash = nextUniqueHash(c, assigned);
|
|
87
92
|
hashes[i] = hash;
|
|
88
93
|
}
|
|
89
94
|
return hashes;
|
|
@@ -93,12 +98,13 @@ export async function lineHashes(
|
|
|
93
98
|
content: string,
|
|
94
99
|
path?: string,
|
|
95
100
|
previous?: { content: string; hashes: string[]; removedHashes?: Set<string> },
|
|
101
|
+
store?: HashStore,
|
|
96
102
|
): Promise<string[]> {
|
|
97
103
|
if (!path) {
|
|
98
104
|
return _lineHashesPure(content);
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
const
|
|
107
|
+
const hashStore = store ?? await loadHashStore();
|
|
102
108
|
|
|
103
109
|
if (previous) {
|
|
104
110
|
const newHashes = mapStableHashes(
|
|
@@ -106,19 +112,19 @@ export async function lineHashes(
|
|
|
106
112
|
content,
|
|
107
113
|
previous.removedHashes,
|
|
108
114
|
);
|
|
109
|
-
|
|
110
|
-
await saveHashStore(
|
|
115
|
+
hashStore.snapshots[path] = { content, hashes: newHashes };
|
|
116
|
+
await saveHashStore(hashStore);
|
|
111
117
|
return newHashes;
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
const snapshot =
|
|
120
|
+
const snapshot = hashStore.snapshots[path];
|
|
115
121
|
if (snapshot && snapshot.content === content) {
|
|
116
122
|
return snapshot.hashes;
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
const newHashes = _lineHashesPure(content);
|
|
120
|
-
|
|
121
|
-
await saveHashStore(
|
|
126
|
+
hashStore.snapshots[path] = { content, hashes: newHashes };
|
|
127
|
+
await saveHashStore(hashStore);
|
|
122
128
|
return newHashes;
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -168,14 +174,7 @@ function mapStableHashes(
|
|
|
168
174
|
for (let i = 0; i < newLines.length; i++) {
|
|
169
175
|
if (newHashes[i]) continue;
|
|
170
176
|
const c = canon(newLines[i]!);
|
|
171
|
-
|
|
172
|
-
let hash = h2s(xxh32(c));
|
|
173
|
-
while (used.has(hash)) {
|
|
174
|
-
retry++;
|
|
175
|
-
if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
|
|
176
|
-
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
177
|
-
}
|
|
178
|
-
used.add(hash);
|
|
177
|
+
const hash = nextUniqueHash(c, used);
|
|
179
178
|
newHashes[i] = hash;
|
|
180
179
|
}
|
|
181
180
|
return newHashes;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf } from "../
|
|
1
|
+
import { abortIf } from "../utils";
|
|
2
2
|
import { rejectUnknownFields } from "../utils";
|
|
3
3
|
import { HL_BARE_PREFIX_RE } from "./hash";
|
|
4
4
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
@@ -94,7 +94,7 @@ export function fmtMismatch(
|
|
|
94
94
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
95
95
|
if (notFound.length > 0) {
|
|
96
96
|
out.push(
|
|
97
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
|
|
97
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
if (ambiguous.length > 0) {
|
|
@@ -153,9 +153,19 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
|
|
|
153
153
|
if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
|
|
154
154
|
const val = edit.content_lines;
|
|
155
155
|
if (typeof val === "string") {
|
|
156
|
-
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(val);
|
|
158
|
+
if (Array.isArray(parsed)) {
|
|
159
|
+
edit.content_lines = parsed;
|
|
160
|
+
} else {
|
|
161
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
|
|
157
168
|
}
|
|
158
|
-
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
|
|
159
169
|
}
|
|
160
170
|
if (!isStrPair(edit.hash_range_inclusive)) {
|
|
161
171
|
throw new Error(
|
|
@@ -230,6 +240,20 @@ export function descEdit(edit: RHEdit): string {
|
|
|
230
240
|
return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
|
|
231
241
|
}
|
|
232
242
|
|
|
243
|
+
function lastNonEmpty(lines: string[]): string | undefined {
|
|
244
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
245
|
+
if (lines[i]!.length > 0) return lines[i]!;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function firstNonEmpty(lines: string[]): string | undefined {
|
|
251
|
+
for (let i = 0; i < lines.length; i++) {
|
|
252
|
+
if (lines[i]!.length > 0) return lines[i]!;
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
233
257
|
function checkBoundaryDup(
|
|
234
258
|
adjacentLine: string | undefined,
|
|
235
259
|
replacementEdge: string | undefined,
|
|
@@ -296,11 +320,11 @@ export function valEdits(
|
|
|
296
320
|
}
|
|
297
321
|
const endLine = endResolved.line;
|
|
298
322
|
const nextLine = fileLines[endLine];
|
|
299
|
-
const replacementLastLine = edit.content_lines
|
|
323
|
+
const replacementLastLine = lastNonEmpty(edit.content_lines);
|
|
300
324
|
const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, resolved.length);
|
|
301
325
|
if (trailing) boundaryWarnings.push(trailing);
|
|
302
326
|
const prevLine = fileLines[startResolved.line - 2];
|
|
303
|
-
const replacementFirstLine = edit.content_lines
|
|
327
|
+
const replacementFirstLine = firstNonEmpty(edit.content_lines);
|
|
304
328
|
const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, resolved.length);
|
|
305
329
|
if (leading) boundaryWarnings.push(leading);
|
|
306
330
|
resolved.push({
|
package/src/paths.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
|
-
import { join, dirname } from "path";
|
|
2
|
+
import { isAbsolute, resolve as resolvePath, join, dirname } from "path";
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
export function configDir(): string {
|
|
@@ -17,3 +17,14 @@ export function hashStorePath(): string {
|
|
|
17
17
|
export function hashStoreDir(): string {
|
|
18
18
|
return dirname(hashStorePath());
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
function expand(filePath: string): string {
|
|
22
|
+
if (filePath === "~") return homedir();
|
|
23
|
+
if (filePath.startsWith("~/")) return homedir() + filePath.slice(1);
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toCwd(filePath: string, cwd: string): string {
|
|
28
|
+
const expanded = expand(filePath);
|
|
29
|
+
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
|
30
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { Type } from "typebox";
|
|
|
11
11
|
import { loadFileKindAndText } from "./file-kind";
|
|
12
12
|
import { readNormFile } from "./file-reader";
|
|
13
13
|
import { lineHashes, fmtRegion, HASH_SEP } from "./hashline";
|
|
14
|
-
import { toCwd } from "./
|
|
15
|
-
import { abortIf } from "./
|
|
16
|
-
import { fileSnap } from "./
|
|
14
|
+
import { toCwd } from "./paths";
|
|
15
|
+
import { abortIf } from "./utils";
|
|
16
|
+
import { fileSnap } from "./file-reader";
|
|
17
17
|
import { visLines } from "./utils";
|
|
18
18
|
import { loadP, loadGuide } from "./prompts";
|
|
19
19
|
import { valAccess } from "./validation";
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { isRec, has } from "./utils";
|
|
2
2
|
import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
|
|
4
|
+
function tryParseContentLines(record: Record<string, unknown>, key: string, label: string): void {
|
|
5
|
+
const val = record[key];
|
|
6
|
+
if (typeof val !== "string") return;
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(val);
|
|
9
|
+
if (Array.isArray(parsed)) {
|
|
10
|
+
record[key] = parsed;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
// fall through to error
|
|
15
|
+
}
|
|
16
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
@@ -27,6 +34,17 @@ function normalizeField(
|
|
|
27
34
|
record[to] = raw;
|
|
28
35
|
} else if (isRec(raw)) {
|
|
29
36
|
record[to] = [raw];
|
|
37
|
+
} else if (typeof raw === "string") {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (Array.isArray(parsed)) {
|
|
41
|
+
record[to] = parsed;
|
|
42
|
+
} else if (isRec(parsed)) {
|
|
43
|
+
record[to] = [parsed];
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// not valid JSON, leave as-is for downstream validation
|
|
47
|
+
}
|
|
30
48
|
}
|
|
31
49
|
if (from !== to) delete record[from];
|
|
32
50
|
}
|
|
@@ -41,7 +59,7 @@ export function normReq(input: unknown): unknown {
|
|
|
41
59
|
normalizeFilePath(record);
|
|
42
60
|
|
|
43
61
|
if (has(record, "content_lines") && typeof record.content_lines === "string") {
|
|
44
|
-
|
|
62
|
+
tryParseContentLines(record, "content_lines", "Top-level");
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
normalizeField(record, "changes", "changes");
|
|
@@ -51,7 +69,7 @@ export function normReq(input: unknown): unknown {
|
|
|
51
69
|
for (let i = 0; i < record.changes.length; i++) {
|
|
52
70
|
const item = record.changes[i];
|
|
53
71
|
if (isRec(item) && has(item, "content_lines") && typeof item.content_lines === "string") {
|
|
54
|
-
|
|
72
|
+
tryParseContentLines(item, "content_lines", `changes[${i}]`);
|
|
55
73
|
}
|
|
56
74
|
}
|
|
57
75
|
}
|
package/src/replace-undo.ts
CHANGED
|
@@ -2,13 +2,32 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import { getUndo, clearUndo } from "./undo-store";
|
|
6
5
|
import { loadHashStore, saveHashStore } from "./hash-store";
|
|
7
6
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
|
-
import { toCwd } from "./
|
|
7
|
+
import { toCwd } from "./paths";
|
|
9
8
|
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
10
9
|
import { cntDiff } from "./utils";
|
|
11
10
|
import { loadP, loadGuide } from "./prompts";
|
|
11
|
+
export interface UndoEntry {
|
|
12
|
+
content: string;
|
|
13
|
+
bom: string;
|
|
14
|
+
originalEnding: "\r\n" | "\n";
|
|
15
|
+
hashes: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const undoMap = new Map<string, UndoEntry>();
|
|
19
|
+
|
|
20
|
+
export function saveUndo(path: string, entry: UndoEntry): void {
|
|
21
|
+
undoMap.set(path, entry);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getUndo(path: string): UndoEntry | undefined {
|
|
25
|
+
return undoMap.get(path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clearUndo(path: string): void {
|
|
29
|
+
undoMap.delete(path);
|
|
30
|
+
}
|
|
12
31
|
|
|
13
32
|
|
|
14
33
|
export function regReplaceUndo(pi: ExtensionAPI): void {
|
package/src/replace.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "./replace-diff";
|
|
13
13
|
import { readNormFile } from "./file-reader";
|
|
14
14
|
import { normReq, normalizeFilePath } from "./replace-normalize";
|
|
15
|
-
import { isRec, has, rejectUnknownFields } from "./utils";
|
|
15
|
+
import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
|
|
16
16
|
import { MAX_HASH_LINES } from "./constants";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
18
|
import {
|
|
@@ -21,9 +21,8 @@ import {
|
|
|
21
21
|
resEdits,
|
|
22
22
|
type HTEdit,
|
|
23
23
|
} from "./hashline";
|
|
24
|
-
import { toCwd } from "./
|
|
25
|
-
import {
|
|
26
|
-
import { fileSnap } from "./snapshot";
|
|
24
|
+
import { toCwd } from "./paths";
|
|
25
|
+
import { fileSnap } from "./file-reader";
|
|
27
26
|
import {
|
|
28
27
|
buildChanged,
|
|
29
28
|
buildNoop,
|
|
@@ -42,8 +41,8 @@ import {
|
|
|
42
41
|
type RRState,
|
|
43
42
|
} from "./replace-render";
|
|
44
43
|
import { loadP, loadGuide } from "./prompts";
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
44
|
+
import { saveUndo } from "./replace-undo";
|
|
45
|
+
import { loadHashStore, type HashStore } from "./hash-store";
|
|
47
46
|
|
|
48
47
|
const contentLinesSchema = Type.Array(Type.String(), {
|
|
49
48
|
description:
|
|
@@ -114,10 +113,11 @@ interface PipelineResult {
|
|
|
114
113
|
resultHashes: string[];
|
|
115
114
|
}
|
|
116
115
|
|
|
117
|
-
const ROOT_KS = new Set(["path", "changes"]);
|
|
116
|
+
const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
|
|
118
117
|
|
|
119
118
|
export function assertReq(
|
|
120
119
|
request: unknown,
|
|
120
|
+
flat?: boolean
|
|
121
121
|
): asserts request is ReqParams {
|
|
122
122
|
if (!isRec(request)) {
|
|
123
123
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
@@ -138,15 +138,20 @@ export function assertReq(
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
if (!Array.isArray(request.changes)) {
|
|
141
|
+
if (flat) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
'[E_BAD_SHAPE] Edit request requires both "content_lines" and "hash_range_inclusive" at the top level.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
141
146
|
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
|
|
142
147
|
}
|
|
143
148
|
}
|
|
144
|
-
|
|
145
149
|
export async function execPipeline(
|
|
146
150
|
params: ReqParams,
|
|
147
151
|
cwd: string,
|
|
148
152
|
accessMode: number,
|
|
149
153
|
signal?: AbortSignal,
|
|
154
|
+
store?: HashStore,
|
|
150
155
|
): Promise<PipelineResult> {
|
|
151
156
|
|
|
152
157
|
const path = params.path;
|
|
@@ -158,8 +163,10 @@ export async function execPipeline(
|
|
|
158
163
|
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "changes" array.');
|
|
159
164
|
}
|
|
160
165
|
|
|
166
|
+
const hashStore = store ?? await loadHashStore();
|
|
167
|
+
|
|
161
168
|
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
162
|
-
path, cwd, signal, accessMode, undefined, MAX_HASH_LINES,
|
|
169
|
+
path, cwd, signal, accessMode, undefined, MAX_HASH_LINES, hashStore,
|
|
163
170
|
);
|
|
164
171
|
|
|
165
172
|
const absolutePath = toCwd(path, cwd);
|
|
@@ -192,7 +199,7 @@ export async function execPipeline(
|
|
|
192
199
|
content: originalNormalized,
|
|
193
200
|
hashes: originalHashes,
|
|
194
201
|
removedHashes,
|
|
195
|
-
});
|
|
202
|
+
}, hashStore);
|
|
196
203
|
|
|
197
204
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
198
205
|
return {
|
|
@@ -215,10 +222,11 @@ export async function execPipeline(
|
|
|
215
222
|
export async function compPreview(
|
|
216
223
|
request: unknown,
|
|
217
224
|
cwd: string,
|
|
225
|
+
flat?: boolean
|
|
218
226
|
): Promise<RPreview> {
|
|
219
227
|
try {
|
|
220
228
|
const normalized = normReq(request);
|
|
221
|
-
assertReq(normalized);
|
|
229
|
+
assertReq(normalized, flat);
|
|
222
230
|
const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
|
|
223
231
|
normalized,
|
|
224
232
|
cwd,
|
|
@@ -259,17 +267,56 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
259
267
|
return m;
|
|
260
268
|
}
|
|
261
269
|
|
|
262
|
-
export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
263
|
-
const autoRead =
|
|
270
|
+
export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolDef {
|
|
271
|
+
const autoRead = opts.autoRead ?? false;
|
|
264
272
|
const readGuidance = autoRead
|
|
265
273
|
? "Anchors are provided automatically after write and replace operations when auto-read is enabled."
|
|
266
274
|
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
267
275
|
|
|
268
|
-
const
|
|
276
|
+
const modeDesc = opts.flat
|
|
277
|
+
? " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object."
|
|
278
|
+
: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.";
|
|
279
|
+
|
|
280
|
+
const modeExamples = opts.flat
|
|
281
|
+
? [
|
|
282
|
+
"", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
|
|
283
|
+
].join("\n")
|
|
284
|
+
: [
|
|
285
|
+
"", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
|
|
286
|
+
].join("\n")
|
|
287
|
+
|
|
288
|
+
const modeRules = opts.flat
|
|
289
|
+
? ""
|
|
290
|
+
: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT]."
|
|
291
|
+
|
|
292
|
+
const modeRequestStructure = opts.flat
|
|
293
|
+
? [
|
|
294
|
+
"Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
|
|
295
|
+
].join("\n")
|
|
296
|
+
: [
|
|
297
|
+
"Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
|
|
298
|
+
].join("\n")
|
|
299
|
+
|
|
300
|
+
const modePrefix = opts.flat
|
|
301
|
+
? "one edit per call (flat mode)"
|
|
302
|
+
: "batching all changes to a file in one call"
|
|
303
|
+
|
|
304
|
+
const modeGuidePrefix = opts.flat
|
|
305
|
+
? "- Use `replace` with HASH anchors for all file changes. Only one edit per call."
|
|
306
|
+
: "- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call."
|
|
307
|
+
|
|
308
|
+
const E_DESC = loadP("../prompts/replace.md", {
|
|
309
|
+
MODE_DESCRIPTION: modeDesc,
|
|
310
|
+
MODE_EXAMPLES: modeExamples,
|
|
311
|
+
MODE_RULES: modeRules,
|
|
312
|
+
MODE_REQUEST_STRUCTURE: modeRequestStructure,
|
|
269
313
|
AUTO_READ_GUIDANCE: readGuidance,
|
|
270
314
|
});
|
|
271
|
-
const E_SNIPPET = loadP(
|
|
272
|
-
|
|
315
|
+
const E_SNIPPET = loadP("../prompts/replace-snippet.md", {
|
|
316
|
+
MODE_PREFIX: modePrefix,
|
|
317
|
+
});
|
|
318
|
+
const E_GUIDE = loadGuide("../prompts/replace-guidelines.md", {
|
|
319
|
+
MODE_PREFIX: modeGuidePrefix,
|
|
273
320
|
AUTO_READ_GUIDANCE: readGuidance,
|
|
274
321
|
});
|
|
275
322
|
|
|
@@ -311,7 +358,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
311
358
|
context.state.preview = undefined;
|
|
312
359
|
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
313
360
|
context.state.previewGeneration = previewGeneration;
|
|
314
|
-
compPreview(previewInput, context.cwd)
|
|
361
|
+
compPreview(previewInput, context.cwd, opts.flat)
|
|
315
362
|
.then((preview) => {
|
|
316
363
|
if (
|
|
317
364
|
context.state.argsKey === argsKey &&
|
|
@@ -472,6 +519,14 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
472
519
|
};
|
|
473
520
|
}
|
|
474
521
|
|
|
475
|
-
export function regReplace(pi: ExtensionAPI): void {
|
|
476
|
-
pi.registerTool(buildToolDef({ flat: false }));
|
|
522
|
+
export function regReplace(pi: ExtensionAPI, autoRead?: boolean): void {
|
|
523
|
+
pi.registerTool(buildToolDef({ flat: false, autoRead }));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function buildToolDefFlat(autoRead?: boolean) {
|
|
527
|
+
return buildToolDef({ flat: true, autoRead });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function regReplaceFlat(pi: ExtensionAPI, autoRead?: boolean): void {
|
|
531
|
+
pi.registerTool(buildToolDef({ flat: true, autoRead }));
|
|
477
532
|
}
|
package/src/utils.ts
CHANGED
|
@@ -12,9 +12,6 @@ export function visLines(text: string): string[] {
|
|
|
12
12
|
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function cntLines(text: string): number {
|
|
16
|
-
return visLines(text).length;
|
|
17
|
-
}
|
|
18
15
|
|
|
19
16
|
export function rejectUnknownFields(
|
|
20
17
|
obj: Record<string, unknown>,
|
|
@@ -44,3 +41,14 @@ export function cntDiff(diff: string, marker: "+" | "-"): number {
|
|
|
44
41
|
}
|
|
45
42
|
return count;
|
|
46
43
|
}
|
|
44
|
+
|
|
45
|
+
export function abortIf(signal?: AbortSignal): void {
|
|
46
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function errCode(error: unknown): string | undefined {
|
|
50
|
+
if (error instanceof Error) {
|
|
51
|
+
return (error as NodeJS.ErrnoException).code;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
package/src/validation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
2
|
import { access as fsAccess } from "fs/promises";
|
|
3
3
|
import type { LFile } from "./file-kind";
|
|
4
|
+
import { errCode } from "./utils";
|
|
4
5
|
|
|
5
6
|
export async function valAccess(
|
|
6
7
|
absolutePath: string,
|
|
@@ -34,17 +35,8 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export function assertText(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
|
|
38
|
-
valKind(file, path);
|
|
39
|
-
}
|
|
40
38
|
|
|
41
39
|
export function isText(file: LFile): file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
|
|
42
40
|
return file.kind === "text";
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
export function errCode(error: unknown): string | undefined {
|
|
46
|
-
if (error instanceof Error) {
|
|
47
|
-
return (error as NodeJS.ErrnoException).code;
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
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 shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
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
|
-
- `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.
|
|
6
|
-
- **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Replace lines in a text file via HASH anchors from read, batching all changes to a file in one call
|
package/prompts/replace-bulk.md
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
Replace lines in a text file using HASH anchors from `read`.
|
|
2
|
-
|
|
3
|
-
Put all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.
|
|
4
|
-
|
|
5
|
-
Examples:
|
|
6
|
-
|
|
7
|
-
1. Single line replace:
|
|
8
|
-
```json
|
|
9
|
-
{ "changes": [
|
|
10
|
-
{ "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"] }
|
|
11
|
-
], "path": "src/main.ts" }
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
2. Range replace (3 lines → 3 new lines):
|
|
15
|
-
```json
|
|
16
|
-
{ "changes": [
|
|
17
|
-
{ "content_lines": [
|
|
18
|
-
"function greet(name) {",
|
|
19
|
-
" return `Hello, ${name}`;",
|
|
20
|
-
"}"
|
|
21
|
-
], "hash_range_inclusive": ["ZPM", "VRW"] }
|
|
22
|
-
], "path": "src/main.ts" }
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
3. Multiple regions in one call (delete two non-adjacent ranges):
|
|
26
|
-
```json
|
|
27
|
-
{ "changes": [
|
|
28
|
-
{ "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"] },
|
|
29
|
-
{ "content_lines": [], "hash_range_inclusive": ["MQX", "ZPM"] }
|
|
30
|
-
], "path": "src/server.ts" }
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
4. Append after the last line (include the old last line so the new line is added after it):
|
|
34
|
-
```json
|
|
35
|
-
{ "changes": [
|
|
36
|
-
{ "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"] }
|
|
37
|
-
], "path": "src/main.ts" }
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
|
|
41
|
-
|
|
42
|
-
Wrong:
|
|
43
|
-
```json
|
|
44
|
-
{ "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
45
|
-
|
|
46
|
-
Right:
|
|
47
|
-
```json
|
|
48
|
-
{ "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
49
|
-
|
|
50
|
-
`hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
51
|
-
|
|
52
|
-
⚠️ Common mistake: `hash_range_inclusive` is only the 3-character HASH, not the full `HASH│content` line.
|
|
53
|
-
|
|
54
|
-
Wrong:
|
|
55
|
-
```json
|
|
56
|
-
{ "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
|
|
57
|
-
|
|
58
|
-
Right:
|
|
59
|
-
```json
|
|
60
|
-
{ "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
61
|
-
|
|
62
|
-
⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
|
|
63
|
-
|
|
64
|
-
Wrong:
|
|
65
|
-
```json
|
|
66
|
-
{ "changes": [{ "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"] }], "path": "src/main.ts" }
|
|
67
|
-
|
|
68
|
-
Right:
|
|
69
|
-
```json
|
|
70
|
-
{ "changes": [{ "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"] }], "path": "src/main.ts" }
|
|
71
|
-
|
|
72
|
-
`content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
|
|
73
|
-
|
|
74
|
-
Rules:
|
|
75
|
-
- `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
|
|
76
|
-
- To delete a range, use `content_lines: []`.
|
|
77
|
-
- `hash_range_inclusive` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
|
|
78
|
-
- `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix. A line that happens to start with `+` or `-` is written as-is; the only rejected form is the diff preview's `+HASH│…` row (see `[E_INVALID_PATCH]`).
|
|
79
|
-
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged. Dropping indentation will produce broken code.
|
|
80
|
-
- Don't add `""` for spacing unless you actually want a new blank line.
|
|
81
|
-
- `changes`, `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.
|
|
82
|
-
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
83
|
-
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
84
|
-
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
85
|
-
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}}
|
|
86
|
-
⚠️ 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.
|
|
87
|
-
|
|
88
|
-
Wrong: To replace lines 2-3 in this function:
|
|
89
|
-
```
|
|
90
|
-
function greet() {
|
|
91
|
-
const x = 1;
|
|
92
|
-
return x;
|
|
93
|
-
}
|
|
94
|
-
```
|
|
95
|
-
A model might write:
|
|
96
|
-
```json
|
|
97
|
-
{ "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
|
|
98
|
-
This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
|
|
99
|
-
|
|
100
|
-
Right: Only include the new lines that belong in the range:
|
|
101
|
-
```json
|
|
102
|
-
{ "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
|
|
103
|
-
The `}` on line 4 is outside the range and stays in place.
|
|
104
|
-
|
|
105
|
-
**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,6 +0,0 @@
|
|
|
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 shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
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
|
-
- `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.
|
|
6
|
-
- **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Replace lines in a text file via HASH anchors from read, one edit per call (flat mode)
|
package/prompts/replace-flat.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
Replace lines in a text file using HASH anchors from `read`. Only one edit per call (no bulk `changes` array — `hash_range_inclusive` and `content_lines` sit at the top level).
|
|
2
|
-
|
|
3
|
-
Examples:
|
|
4
|
-
|
|
5
|
-
1. Single line replace:
|
|
6
|
-
```json
|
|
7
|
-
{ "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
2. Range replace (3 lines → 3 new lines):
|
|
11
|
-
```json
|
|
12
|
-
{ "content_lines": [
|
|
13
|
-
"function greet(name) {",
|
|
14
|
-
" return `Hello, ${name}`;",
|
|
15
|
-
"}"
|
|
16
|
-
], "hash_range_inclusive": ["ZPM", "VRW"], "path": "src/main.ts" }
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
3. Delete a range:
|
|
20
|
-
```json
|
|
21
|
-
{ "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"], "path": "src/server.ts" }
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
4. Append after the last line (include the old last line so the new line is added after it):
|
|
25
|
-
```json
|
|
26
|
-
{ "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"], "path": "src/main.ts" }
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
|
|
30
|
-
|
|
31
|
-
Wrong:
|
|
32
|
-
```json
|
|
33
|
-
{ "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
34
|
-
|
|
35
|
-
Right:
|
|
36
|
-
```json
|
|
37
|
-
{ "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
38
|
-
|
|
39
|
-
`hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
40
|
-
|
|
41
|
-
⚠️ Common mistake: `hash_range_inclusive` is only the 3-character HASH, not the full `HASH│content` line.
|
|
42
|
-
|
|
43
|
-
Wrong:
|
|
44
|
-
```json
|
|
45
|
-
{ "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
|
|
46
|
-
|
|
47
|
-
Right:
|
|
48
|
-
```json
|
|
49
|
-
{ "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
50
|
-
|
|
51
|
-
⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
|
|
52
|
-
|
|
53
|
-
Wrong:
|
|
54
|
-
```json
|
|
55
|
-
{ "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"], "path": "src/main.ts" }
|
|
56
|
-
|
|
57
|
-
Right:
|
|
58
|
-
```json
|
|
59
|
-
{ "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"], "path": "src/main.ts" }
|
|
60
|
-
|
|
61
|
-
`content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
|
|
62
|
-
|
|
63
|
-
Rules:
|
|
64
|
-
- `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
|
|
65
|
-
- To delete a range, use `content_lines: []`.
|
|
66
|
-
- `hash_range_inclusive` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
|
|
67
|
-
- `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix. A line that happens to start with `+` or `-` is written as-is; the only rejected form is the diff preview's `+HASH│…` row (see `[E_INVALID_PATCH]`).
|
|
68
|
-
- **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged. Dropping indentation will produce broken code.
|
|
69
|
-
- Don't add `""` for spacing unless you actually want a new blank line.
|
|
70
|
-
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
71
|
-
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
72
|
-
- 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.
|
|
73
|
-
- `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.
|
|
74
|
-
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}}
|
|
75
|
-
|
|
76
|
-
⚠️ 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.
|
|
77
|
-
|
|
78
|
-
Wrong: To replace lines 2-3 in this function:
|
|
79
|
-
```
|
|
80
|
-
function greet() {
|
|
81
|
-
const x = 1;
|
|
82
|
-
return x;
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
A model might write:
|
|
86
|
-
```json
|
|
87
|
-
{ "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
|
|
88
|
-
This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
|
|
89
|
-
|
|
90
|
-
Right: Only include the new lines that belong in the range:
|
|
91
|
-
```json
|
|
92
|
-
{ "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
|
|
93
|
-
The `}` on line 4 is outside the range and stays in place.
|
|
94
|
-
|
|
95
|
-
**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.
|
package/src/path-utils.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import * as os from "os";
|
|
2
|
-
import { isAbsolute, resolve as resolvePath } from "path";
|
|
3
|
-
|
|
4
|
-
function expand(filePath: string): string {
|
|
5
|
-
if (filePath === "~") return os.homedir();
|
|
6
|
-
if (filePath.startsWith("~/")) return os.homedir() + filePath.slice(1);
|
|
7
|
-
return filePath;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function toCwd(filePath: string, cwd: string): string {
|
|
11
|
-
const expanded = expand(filePath);
|
|
12
|
-
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
|
13
|
-
}
|
package/src/replace-flat.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
} from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
5
|
-
buildToolDef,
|
|
6
|
-
flatEditToolSchema,
|
|
7
|
-
} from "./replace";
|
|
8
|
-
|
|
9
|
-
export { flatEditToolSchema };
|
|
10
|
-
|
|
11
|
-
export function buildToolDefFlat() {
|
|
12
|
-
return buildToolDef({ flat: true });
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function regReplaceFlat(pi: ExtensionAPI): void {
|
|
16
|
-
pi.registerTool(buildToolDef({ flat: true }));
|
|
17
|
-
}
|
package/src/runtime.ts
DELETED
package/src/snapshot.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { stat } from "fs/promises";
|
|
2
|
-
import { resolveTarget } from "./fs-write";
|
|
3
|
-
|
|
4
|
-
export type SnapInfo = {
|
|
5
|
-
snapshotId: string;
|
|
6
|
-
mtimeMs: number;
|
|
7
|
-
size: number;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
function fmtSnapId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
|
|
11
|
-
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
|
|
15
|
-
const canonicalPath = await resolveTarget(absolutePath);
|
|
16
|
-
const stats = await stat(canonicalPath);
|
|
17
|
-
return {
|
|
18
|
-
snapshotId: fmtSnapId(canonicalPath, stats),
|
|
19
|
-
mtimeMs: stats.mtimeMs,
|
|
20
|
-
size: stats.size,
|
|
21
|
-
};
|
|
22
|
-
}
|
package/src/undo-store.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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
|
-
}
|