pi-hashline-edit-pro 4.5.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -25
- package/index.ts +15 -2
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +1 -1
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -1
- package/prompts/replace-guidelines.md +2 -4
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace-within-guidelines.md +3 -0
- package/prompts/replace-within-snippet.md +1 -0
- package/prompts/replace-within.md +5 -0
- package/prompts/replace.md +3 -2
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +29 -7
- package/src/commit.ts +16 -13
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +6 -0
- package/src/copy-move.ts +15 -11
- package/src/edit-common.ts +66 -15
- package/src/edit-fidelity.ts +108 -0
- package/src/grep.ts +6 -5
- package/src/hashline/apply.ts +7 -5
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +15 -4
- package/src/hashline/resolve.ts +67 -38
- package/src/insert.ts +21 -34
- package/src/payload-contract.ts +155 -28
- package/src/replace-render.ts +4 -4
- package/src/replace-response.ts +3 -1
- package/src/replace-within.ts +227 -0
- package/src/replace.ts +26 -15
- package/src/utils.ts +28 -14
- package/prompts/grep-guidelines.md +0 -2
package/src/config.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface Config {
|
|
|
14
14
|
autoRead: boolean;
|
|
15
15
|
anchorGrepEnabled: boolean;
|
|
16
16
|
copyMoveEnabled?: boolean;
|
|
17
|
+
replaceWithinEnabled?: boolean;
|
|
17
18
|
autoReadAll?: AutoReadAllMode;
|
|
18
19
|
autoReadAllIgnore?: string[];
|
|
19
20
|
requirePath?: boolean;
|
|
@@ -25,6 +26,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
25
26
|
autoRead: true,
|
|
26
27
|
anchorGrepEnabled: true,
|
|
27
28
|
copyMoveEnabled: true,
|
|
29
|
+
replaceWithinEnabled: true,
|
|
28
30
|
autoReadAll: "off",
|
|
29
31
|
autoReadAllIgnore: [],
|
|
30
32
|
requirePath: false,
|
|
@@ -75,6 +77,7 @@ function parseConfig(content: string): Config {
|
|
|
75
77
|
const autoRead = parsed.autoRead;
|
|
76
78
|
const anchorGrepEnabled = parsed.anchorGrepEnabled;
|
|
77
79
|
const copyMoveEnabled = parsed.copyMoveEnabled;
|
|
80
|
+
const replaceWithinEnabled = parsed.replaceWithinEnabled;
|
|
78
81
|
const autoReadAll = parsed.autoReadAll;
|
|
79
82
|
const requirePath = parsed.requirePath;
|
|
80
83
|
const strictInput = parsed.strictInput;
|
|
@@ -84,6 +87,7 @@ function parseConfig(content: string): Config {
|
|
|
84
87
|
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
85
88
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
86
89
|
copyMoveEnabled: typeof copyMoveEnabled === "boolean" ? copyMoveEnabled : DEFAULT_CONFIG.copyMoveEnabled,
|
|
90
|
+
replaceWithinEnabled: typeof replaceWithinEnabled === "boolean" ? replaceWithinEnabled : DEFAULT_CONFIG.replaceWithinEnabled,
|
|
87
91
|
autoReadAll: parseAutoReadAllMode(autoReadAll),
|
|
88
92
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
89
93
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
@@ -196,7 +200,7 @@ export async function writeConfig(config: Config): Promise<void> {
|
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
|
|
199
|
-
type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput";
|
|
203
|
+
type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceWithinEnabled" | "requirePath" | "strictInput";
|
|
200
204
|
|
|
201
205
|
async function toggleFlag(key: ToggleKey): Promise<boolean> {
|
|
202
206
|
const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
|
|
@@ -205,6 +209,7 @@ async function toggleFlag(key: ToggleKey): Promise<boolean> {
|
|
|
205
209
|
export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
|
|
206
210
|
export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
|
|
207
211
|
export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
|
|
212
|
+
export const toggleReplaceWithin = (): Promise<boolean> => toggleFlag("replaceWithinEnabled");
|
|
208
213
|
export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
|
|
209
214
|
let next: AutoReadAllMode = "off";
|
|
210
215
|
await updateConfig((c) => {
|
package/src/constants.ts
CHANGED
|
@@ -13,6 +13,12 @@ export const HASH_STORE_VERSION = 9;
|
|
|
13
13
|
export const NEW_CONTENT_NOT_ARRAY_MSG =
|
|
14
14
|
`[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
|
|
15
15
|
|
|
16
|
+
export const NEW_CONTENT_NOT_STRING_MSG =
|
|
17
|
+
`[E_BAD_SHAPE] "replacement_lines" must be a string holding the exact text to write. Use "" to delete the range and "\\n" for one blank line; line breaks inside the string separate lines.`;
|
|
18
|
+
|
|
19
|
+
export const LINES_NOT_STRING_MSG =
|
|
20
|
+
`[E_BAD_SHAPE] "lines" must be a string holding the exact text to insert. Use "" to insert nothing and "\\n" for one blank line; line breaks inside the string separate lines.`;
|
|
21
|
+
|
|
16
22
|
export const NUL_CONTENT_MSG =
|
|
17
23
|
`[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range or inserts nothing.`;
|
|
18
24
|
|
package/src/copy-move.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
|
|
|
2
2
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { constants } from "node:fs";
|
|
5
|
-
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type
|
|
5
|
+
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
|
|
6
6
|
import { commitEdit } from "./commit";
|
|
7
7
|
import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
|
|
8
8
|
import {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveAnchorLine,
|
|
15
15
|
stripAnchorRow,
|
|
16
16
|
type Anchor,
|
|
17
|
+
type HTEdit,
|
|
17
18
|
} from "./hashline";
|
|
18
19
|
import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAnchorSession } from "./anchor-registry";
|
|
19
20
|
import { loadP, loadGuide } from "./prompts";
|
|
@@ -49,7 +50,7 @@ interface TransferRefs {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface TransferPlan {
|
|
52
|
-
editParams:
|
|
53
|
+
editParams: HTEdit;
|
|
53
54
|
foldedAnchorLines: number;
|
|
54
55
|
anchorCarry?: number;
|
|
55
56
|
servedOverride?: ReadonlyMap<string, string>;
|
|
@@ -224,10 +225,10 @@ interface CrossTransferPreparation {
|
|
|
224
225
|
destinationPreload: NormFile;
|
|
225
226
|
sourceDisplay: string;
|
|
226
227
|
destinationDisplay: string;
|
|
227
|
-
destinationEdit:
|
|
228
|
+
destinationEdit: HTEdit;
|
|
228
229
|
destinationFolded: number;
|
|
229
230
|
endingOverrides: (LineEnding | undefined)[];
|
|
230
|
-
sourceEdit?:
|
|
231
|
+
sourceEdit?: HTEdit;
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
async function prepareCrossTransfer(input: {
|
|
@@ -292,7 +293,7 @@ async function prepareCrossTransfer(input: {
|
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
const moved = sourceLines.slice(sourceStart - 1, sourceEnd);
|
|
295
|
-
const destinationEdit:
|
|
296
|
+
const destinationEdit: HTEdit = destinationPreload.normalized.length === 0
|
|
296
297
|
? {
|
|
297
298
|
remove_from: destinationPreload.fileHashes[0]!,
|
|
298
299
|
remove_to: destinationPreload.fileHashes[0]!,
|
|
@@ -309,7 +310,7 @@ async function prepareCrossTransfer(input: {
|
|
|
309
310
|
if (kind === "copy") {
|
|
310
311
|
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, endingOverrides };
|
|
311
312
|
}
|
|
312
|
-
const sourceEdit:
|
|
313
|
+
const sourceEdit: HTEdit = {
|
|
313
314
|
remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
|
|
314
315
|
remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
|
|
315
316
|
replacement_lines: [],
|
|
@@ -544,6 +545,7 @@ async function executeCrossFile(
|
|
|
544
545
|
accessMode: constants.R_OK | constants.W_OK,
|
|
545
546
|
signal,
|
|
546
547
|
preloadedNorm: prepared.destinationPreload,
|
|
548
|
+
endingOverrides: prepared.endingOverrides,
|
|
547
549
|
});
|
|
548
550
|
if (kind === "copy") {
|
|
549
551
|
return commitEdit(destinationPipe, {
|
|
@@ -610,6 +612,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
610
612
|
noPersist: true,
|
|
611
613
|
preloadedNorm: preload,
|
|
612
614
|
served: plan.servedOverride,
|
|
615
|
+
endingOverrides: plan.endingOverrides,
|
|
613
616
|
signal,
|
|
614
617
|
});
|
|
615
618
|
return previewFromPipe(pipe);
|
|
@@ -619,6 +622,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
619
622
|
accessMode: constants.R_OK,
|
|
620
623
|
noPersist: true,
|
|
621
624
|
preloadedNorm: prepared.destinationPreload,
|
|
625
|
+
endingOverrides: prepared.endingOverrides,
|
|
622
626
|
signal,
|
|
623
627
|
});
|
|
624
628
|
if (kind === "copy") return previewFromPipe(destinationPipe);
|
|
@@ -668,19 +672,19 @@ function getTransferInput(args: unknown): { path?: string; source_from?: string;
|
|
|
668
672
|
|
|
669
673
|
const transferSourceFromSchema = Type.String({
|
|
670
674
|
description:
|
|
671
|
-
"
|
|
675
|
+
"4-char anchor of the FIRST source line to copy (never the row content).",
|
|
672
676
|
});
|
|
673
677
|
const transferSourceToSchema = Type.String({
|
|
674
678
|
description:
|
|
675
|
-
"
|
|
679
|
+
"4-char anchor of the LAST source line to copy.",
|
|
676
680
|
});
|
|
677
681
|
const transferInsertAfterSchema = Type.String({
|
|
678
682
|
description:
|
|
679
|
-
"
|
|
683
|
+
"4-char anchor of the destination line; the block goes after it and may live in another file.",
|
|
680
684
|
});
|
|
681
685
|
const transferPathRequiredSchema = Type.String({
|
|
682
686
|
description:
|
|
683
|
-
"Path to the source or destination file the anchors were served for; required and must match anchor ownership.
|
|
687
|
+
"Path to the source or destination file the anchors were served for; required and must match anchor ownership.",
|
|
684
688
|
});
|
|
685
689
|
|
|
686
690
|
const transferToolSchema = Type.Object(
|
|
@@ -715,7 +719,6 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
|
|
|
715
719
|
guidelines: loadGuide(`../prompts/${kind}-guidelines.md`),
|
|
716
720
|
},
|
|
717
721
|
flags,
|
|
718
|
-
kind,
|
|
719
722
|
);
|
|
720
723
|
return {
|
|
721
724
|
name: kind,
|
|
@@ -764,6 +767,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
|
|
|
764
767
|
signal,
|
|
765
768
|
preloadedNorm: preload,
|
|
766
769
|
served: plan.servedOverride,
|
|
770
|
+
endingOverrides: plan.endingOverrides,
|
|
767
771
|
});
|
|
768
772
|
return commitEdit(pipe, {
|
|
769
773
|
path: pipe.path,
|
package/src/edit-common.ts
CHANGED
|
@@ -13,13 +13,17 @@ export interface EditToolFlags {
|
|
|
13
13
|
strictInput: boolean;
|
|
14
14
|
autoRead: boolean;
|
|
15
15
|
autoReadAllActive: boolean;
|
|
16
|
+
replaceWithinEnabled: boolean;
|
|
17
|
+
copyMoveEnabled: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
|
|
19
21
|
requirePath: false,
|
|
20
22
|
strictInput: false,
|
|
21
23
|
autoRead: true,
|
|
22
|
-
autoReadAllActive: false
|
|
24
|
+
autoReadAllActive: false,
|
|
25
|
+
replaceWithinEnabled: true,
|
|
26
|
+
copyMoveEnabled: true
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
@@ -28,7 +32,9 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
|
28
32
|
requirePath: config.requirePath === true,
|
|
29
33
|
strictInput: config.strictInput === true,
|
|
30
34
|
autoRead: config.autoRead !== false,
|
|
31
|
-
autoReadAllActive: (config.autoReadAll ?? "off") !== "off"
|
|
35
|
+
autoReadAllActive: (config.autoReadAll ?? "off") !== "off",
|
|
36
|
+
replaceWithinEnabled: config.replaceWithinEnabled !== false,
|
|
37
|
+
copyMoveEnabled: config.copyMoveEnabled !== false
|
|
32
38
|
};
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -41,18 +47,18 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
41
47
|
guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
|
|
42
48
|
description = description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
|
|
43
49
|
}
|
|
50
|
+
if (!flags.replaceWithinEnabled) {
|
|
51
|
+
description = description.replace(/\n?To change only part of a line without retyping the rest, use `replace_within` instead; it preserves every character the request does not name\./, "");
|
|
52
|
+
}
|
|
44
53
|
const descriptionParts = [description];
|
|
45
54
|
if (flags.requirePath) {
|
|
46
55
|
descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
|
|
47
56
|
snippetParts.push("; include `path` (required)");
|
|
48
|
-
guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
|
|
49
57
|
} else {
|
|
50
58
|
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
51
|
-
guidelines.push("`replace`: path resolution is anchor-only; don't pass `path`.");
|
|
52
59
|
}
|
|
53
60
|
if (flags.strictInput) {
|
|
54
61
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
55
|
-
guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
56
62
|
}
|
|
57
63
|
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
58
64
|
}
|
|
@@ -78,39 +84,84 @@ export function withInsertPrompts(base: { description: string; snippet: string;
|
|
|
78
84
|
if (flags.requirePath) {
|
|
79
85
|
descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
|
|
80
86
|
snippetParts.push("; include `path` (required)");
|
|
81
|
-
guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
|
|
82
87
|
} else {
|
|
83
88
|
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
84
|
-
guidelines.push("`insert`: path resolution is anchor-only; don't pass `path`.");
|
|
85
89
|
}
|
|
86
90
|
if (flags.strictInput) {
|
|
87
91
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
88
|
-
guidelines.push("`insert`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
89
92
|
}
|
|
90
93
|
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
export function withReplaceWithinPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
97
|
+
const descriptionParts = [base.description];
|
|
98
|
+
const snippetParts = [base.snippet];
|
|
99
|
+
const guidelines = base.guidelines.map((guideline) => flags.copyMoveEnabled ? guideline : guideline.replace(", like `copy` and `move`", ""));
|
|
100
|
+
if (flags.requirePath) {
|
|
101
|
+
descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
|
|
102
|
+
snippetParts.push("; include `path` (required)");
|
|
103
|
+
} else {
|
|
104
|
+
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
105
|
+
}
|
|
106
|
+
if (flags.strictInput) {
|
|
107
|
+
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
108
|
+
}
|
|
109
|
+
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
|
|
113
|
+
return ops.filter((op) => {
|
|
114
|
+
if (op === "replace_within") return flags.replaceWithinEnabled;
|
|
115
|
+
if (op === "copy" || op === "move") return flags.copyMoveEnabled;
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/" }): string {
|
|
121
|
+
const formatted = ops.map((op) => (options?.backtick ? `\`${op}\`` : op));
|
|
122
|
+
if (options?.separator === "/") return formatted.join("/");
|
|
123
|
+
if (formatted.length <= 1) return formatted[0] ?? "";
|
|
124
|
+
const last = formatted[formatted.length - 1]!;
|
|
125
|
+
const head = formatted.slice(0, -1).join(", ");
|
|
126
|
+
return formatted.length === 2 ? `${head} or ${last}` : `${head}, or ${last}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function withGrepPrompts(base: { description: string; snippet: string }, flags: EditToolFlags): { description: string; snippet: string } {
|
|
130
|
+
if (flags.copyMoveEnabled) return base;
|
|
131
|
+
return { ...base, description: base.description.replaceAll("replace, insert, copy, or move", joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags))) };
|
|
132
|
+
}
|
|
133
|
+
|
|
93
134
|
export function withUndoPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
135
|
+
const ops = gatedEditOps(["replace", "replace_within", "insert", "copy", "move"], flags);
|
|
136
|
+
let description = base.description;
|
|
137
|
+
let snippet = base.snippet;
|
|
138
|
+
let guidelines = [...base.guidelines];
|
|
139
|
+
if (!flags.autoRead) {
|
|
140
|
+
guidelines = guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`replace_within`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
|
|
141
|
+
}
|
|
142
|
+
if (ops.length !== 5) {
|
|
143
|
+
description = description.replaceAll("replace, replace_within, insert, copy, or move", joinOps(ops));
|
|
144
|
+
snippet = snippet.replaceAll("`replace`, `replace_within`, `insert`, `copy`, or `move`", joinOps(ops, { backtick: true }));
|
|
145
|
+
guidelines = guidelines.map((guideline) => guideline.replaceAll("`replace`/`replace_within`/`insert`/`copy`/`move`", joinOps(ops, { backtick: true, separator: "/" })));
|
|
146
|
+
}
|
|
147
|
+
if (!flags.copyMoveEnabled) {
|
|
148
|
+
guidelines = guidelines.filter((guideline) => !guideline.includes("cross-file `move`"));
|
|
149
|
+
}
|
|
150
|
+
return { description, snippet, guidelines };
|
|
97
151
|
}
|
|
98
152
|
|
|
99
|
-
export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags
|
|
153
|
+
export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
100
154
|
const descriptionParts = [base.description];
|
|
101
155
|
const snippetParts = [base.snippet];
|
|
102
156
|
const guidelines = [...base.guidelines];
|
|
103
157
|
if (flags.requirePath) {
|
|
104
158
|
descriptionParts.push("Also give `path` matching the source or destination file the anchors were served for; it is required and must match anchor ownership.");
|
|
105
159
|
snippetParts.push("; include `path` (required)");
|
|
106
|
-
guidelines.push(`\`${toolName}\`: include \`path\` matching the source or destination file the anchors were served for; it is required.`);
|
|
107
160
|
} else {
|
|
108
161
|
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
109
|
-
guidelines.push(`\`${toolName}\`: path resolution is anchor-only; don't pass \`path\`.`);
|
|
110
162
|
}
|
|
111
163
|
if (flags.strictInput) {
|
|
112
164
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
113
|
-
guidelines.push(`\`${toolName}\`: strict-input is on: auto-fixable slips are rejected instead of fixed.`);
|
|
114
165
|
}
|
|
115
166
|
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
116
167
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { splitLines } from "./utils";
|
|
2
|
+
import type { DiffSpan } from "./replace-diff";
|
|
3
|
+
|
|
4
|
+
const INVISIBLE_RE = /\p{Default_Ignorable_Code_Point}/u;
|
|
5
|
+
const LOOKALIKE_SPACE_RE = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/u;
|
|
6
|
+
const LOOKALIKE_DASH_RE = /[\u2010-\u2015\u2212]/u;
|
|
7
|
+
const LOOKALIKE_QUOTE_RE = /[\u2018\u2019\u201c\u201d]/u;
|
|
8
|
+
const MAX_LISTED_CHARS = 3;
|
|
9
|
+
|
|
10
|
+
const CHAR_NAMES: Readonly<Record<number, string>> = {
|
|
11
|
+
0x00a0: "no-break space",
|
|
12
|
+
0x200b: "zero-width space",
|
|
13
|
+
0x200c: "zero-width non-joiner",
|
|
14
|
+
0x200d: "zero-width joiner",
|
|
15
|
+
0x200e: "left-to-right mark",
|
|
16
|
+
0x200f: "right-to-left mark",
|
|
17
|
+
0x2011: "non-breaking hyphen",
|
|
18
|
+
0x202f: "narrow no-break space",
|
|
19
|
+
0x2060: "word joiner",
|
|
20
|
+
0xfeff: "zero-width no-break space",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const LOOKALIKE_SUBSTITUTES: Readonly<Record<string, string>> = {
|
|
24
|
+
"\u00a0": " ",
|
|
25
|
+
"\u1680": " ",
|
|
26
|
+
"\u2000": " ",
|
|
27
|
+
"\u2001": " ",
|
|
28
|
+
"\u2002": " ",
|
|
29
|
+
"\u2003": " ",
|
|
30
|
+
"\u2004": " ",
|
|
31
|
+
"\u2005": " ",
|
|
32
|
+
"\u2006": " ",
|
|
33
|
+
"\u2007": " ",
|
|
34
|
+
"\u2008": " ",
|
|
35
|
+
"\u2009": " ",
|
|
36
|
+
"\u200a": " ",
|
|
37
|
+
"\u202f": " ",
|
|
38
|
+
"\u205f": " ",
|
|
39
|
+
"\u3000": " ",
|
|
40
|
+
"\u2010": "-",
|
|
41
|
+
"\u2011": "-",
|
|
42
|
+
"\u2012": "-",
|
|
43
|
+
"\u2013": "-",
|
|
44
|
+
"\u2014": "-",
|
|
45
|
+
"\u2015": "-",
|
|
46
|
+
"\u2212": "-",
|
|
47
|
+
"\u2018": "'",
|
|
48
|
+
"\u2019": "'",
|
|
49
|
+
"\u201c": "\"",
|
|
50
|
+
"\u201d": "\"",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function isFidelitySensitiveChar(char: string): boolean {
|
|
54
|
+
return INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function describeChar(char: string): string {
|
|
58
|
+
const codePoint = char.codePointAt(0)!;
|
|
59
|
+
const hex = `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
60
|
+
const name = CHAR_NAMES[codePoint];
|
|
61
|
+
return name === undefined ? hex : `${hex} (${name})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unicodeLostHint(chars: string[]): string {
|
|
65
|
+
const listed = chars.slice(0, MAX_LISTED_CHARS).map(describeChar).join(", ");
|
|
66
|
+
const more = chars.length > MAX_LISTED_CHARS ? ` (+${chars.length - MAX_LISTED_CHARS} more)` : "";
|
|
67
|
+
return `[H_UNICODE_LOST] The removed line contained ${listed}${more}, which the replacement does not. If the request did not ask to remove it, copy the character from the served row.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withCharsRestored(line: string, chars: readonly string[], replacementFor: (char: string) => string): string {
|
|
71
|
+
let out = line;
|
|
72
|
+
for (const char of chars) out = out.split(char).join(replacementFor(char));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isDeliberateCharFix(oldLine: string, newLine: string, lostChars: readonly string[]): boolean {
|
|
77
|
+
if (lostChars.length === 0) return false;
|
|
78
|
+
if (withCharsRestored(oldLine, lostChars, (char) => LOOKALIKE_SUBSTITUTES[char] ?? "") === newLine) return true;
|
|
79
|
+
return withCharsRestored(oldLine, lostChars, () => "") === newLine;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function fidelityHints(originalContent: string, resultContent: string, spans: readonly DiffSpan[] | undefined): string[] {
|
|
83
|
+
if (spans === undefined || spans.length === 0) return [];
|
|
84
|
+
const oldLines = splitLines(originalContent);
|
|
85
|
+
const newLines = splitLines(resultContent);
|
|
86
|
+
const lost = new Set<string>();
|
|
87
|
+
let offset = 0;
|
|
88
|
+
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
|
|
89
|
+
const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
|
|
90
|
+
const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
|
|
91
|
+
const inserted = newLines.slice(span.start + offset, span.start + offset + span.replacementCount);
|
|
92
|
+
if (removed.length > 0 && inserted.length > 0) {
|
|
93
|
+
const insertedText = inserted.join("\n");
|
|
94
|
+
const dropped: string[] = [];
|
|
95
|
+
for (const line of removed) {
|
|
96
|
+
for (const char of line) {
|
|
97
|
+
if (!dropped.includes(char) && isFidelitySensitiveChar(char) && !insertedText.includes(char)) dropped.push(char);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const deliberateFix = removed.length === 1 && inserted.length === 1 && isDeliberateCharFix(removed[0]!, inserted[0]!, dropped);
|
|
101
|
+
if (!deliberateFix) {
|
|
102
|
+
for (const char of dropped) lost.add(char);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
offset += span.replacementCount - removedCount;
|
|
106
|
+
}
|
|
107
|
+
return lost.size > 0 ? [unicodeLostHint([...lost].sort())] : [];
|
|
108
|
+
}
|
package/src/grep.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { globToRegex } from "./glob";
|
|
|
11
11
|
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP, HASH_CLASS } from "./hashline";
|
|
12
12
|
import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
|
|
13
13
|
import { toCwd, toDisplayPath } from "./paths";
|
|
14
|
-
import { loadP
|
|
14
|
+
import { loadP } from "./prompts";
|
|
15
15
|
import { normReq } from "./payload-contract";
|
|
16
|
+
import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
|
|
16
17
|
import { abortIf, clipLine, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
17
18
|
import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
18
19
|
import { serveRows } from "./served";
|
|
@@ -529,13 +530,13 @@ export function renderGrepResult(result: { content?: Array<{ type: string; text?
|
|
|
529
530
|
if (lines.length > maxLines) shown.push(theme.fg("muted", `... ${lines.length - maxLines} more grep lines (${expandHint()})`));
|
|
530
531
|
return reuseText(context, shown.join("\n"));
|
|
531
532
|
}
|
|
532
|
-
export function regGrep(pi: ExtensionAPI): void {
|
|
533
|
+
export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
534
|
+
const prompted = withGrepPrompts({ description: loadP("../prompts/grep.md"), snippet: loadP("../prompts/grep-snippet.md") }, flags);
|
|
533
535
|
pi.registerTool({
|
|
534
536
|
name: "anchor_grep",
|
|
535
537
|
label: "Anchor Grep",
|
|
536
|
-
description:
|
|
537
|
-
promptSnippet:
|
|
538
|
-
promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
|
|
538
|
+
description: prompted.description,
|
|
539
|
+
promptSnippet: prompted.snippet,
|
|
539
540
|
prepareArguments: makePrepareArguments(),
|
|
540
541
|
parameters: grepToolSchema,
|
|
541
542
|
executionMode: "sequential",
|
package/src/hashline/apply.ts
CHANGED
|
@@ -68,12 +68,12 @@ function resToSpan(
|
|
|
68
68
|
const startLine = edit.hash_bounds[0].line;
|
|
69
69
|
const endLine = edit.hash_bounds[1].line;
|
|
70
70
|
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
71
|
-
|
|
71
|
+
const contentUnchanged =
|
|
72
72
|
originalLines.length === edit.content_lines.length &&
|
|
73
73
|
originalLines.every(
|
|
74
74
|
(line, lineIndex) => line === edit.content_lines[lineIndex],
|
|
75
|
-
)
|
|
76
|
-
) {
|
|
75
|
+
);
|
|
76
|
+
if (contentUnchanged && edit.content_separators === undefined) {
|
|
77
77
|
return {
|
|
78
78
|
kind: "noop",
|
|
79
79
|
loc: edit.hash_bounds[0].hash,
|
|
@@ -82,7 +82,9 @@ function resToSpan(
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
if (edit.content_lines.length > 0) {
|
|
85
|
-
const
|
|
85
|
+
const lastIndex = edit.content_lines.length - 1;
|
|
86
|
+
const lastReplacementLine = edit.content_lines[lastIndex]!;
|
|
87
|
+
const lastEnding = edit.content_separators?.[lastIndex];
|
|
86
88
|
const endsWithBlank = lastReplacementLine.length === 0;
|
|
87
89
|
const endsAtEofWithoutNewline =
|
|
88
90
|
endLine === fileLines.length && !content.endsWith("\n");
|
|
@@ -92,7 +94,7 @@ function resToSpan(
|
|
|
92
94
|
start: lineStarts[startLine - 1]!,
|
|
93
95
|
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
94
96
|
replacement:
|
|
95
|
-
endsAtEofWithoutNewline && endsWithBlank
|
|
97
|
+
endsAtEofWithoutNewline && (endsWithBlank || lastEnding !== undefined)
|
|
96
98
|
? `${replacement}\n`
|
|
97
99
|
: replacement,
|
|
98
100
|
};
|
package/src/hashline/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
parseHashRef,
|
|
20
20
|
parseText,
|
|
21
21
|
parseTextWithSeparators,
|
|
22
|
+
parsePayloadText,
|
|
22
23
|
type Anchor,
|
|
23
24
|
type ParsedText,
|
|
24
25
|
} from "./parse";
|
|
@@ -27,6 +28,7 @@ export {
|
|
|
27
28
|
type HEdit,
|
|
28
29
|
type RHEdit,
|
|
29
30
|
type HTEdit,
|
|
31
|
+
type HTPayloadEdit,
|
|
30
32
|
type NEdit,
|
|
31
33
|
resEdit,
|
|
32
34
|
stripAnchorRow,
|
package/src/hashline/parse.ts
CHANGED
|
@@ -81,10 +81,14 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
|
|
|
81
81
|
}
|
|
82
82
|
const lines: string[] = [];
|
|
83
83
|
const separators: (LineEnding | undefined)[] = [];
|
|
84
|
-
for (const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
for (const element of edit) {
|
|
85
|
+
const text = unwrapJsonEnvelope(element);
|
|
86
|
+
const parsed = splitWithEndings(text);
|
|
87
|
+
const lineCount = text.endsWith("\n") || text.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
|
|
88
|
+
for (let index = 0; index < lineCount; index++) {
|
|
89
|
+
lines.push(parsed.lines[index]!);
|
|
90
|
+
separators.push(parsed.endings[index]!);
|
|
91
|
+
}
|
|
88
92
|
}
|
|
89
93
|
return { lines, separators };
|
|
90
94
|
}
|
|
@@ -92,3 +96,10 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
|
|
|
92
96
|
export function parseText(edit: string[]): string[] {
|
|
93
97
|
return parseTextWithSeparators(edit).lines;
|
|
94
98
|
}
|
|
99
|
+
|
|
100
|
+
export function parsePayloadText(text: string): ParsedText {
|
|
101
|
+
if (text.length === 0) return { lines: [], separators: [] };
|
|
102
|
+
const parsed = splitWithEndings(text);
|
|
103
|
+
const lineCount = text.endsWith("\n") || text.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
|
|
104
|
+
return { lines: parsed.lines.slice(0, lineCount), separators: parsed.endings.slice(0, lineCount) };
|
|
105
|
+
}
|