pi-hashline-edit-pro 4.5.3 → 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 +40 -24
- package/index.ts +13 -1
- 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/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +6 -0
- package/src/copy-move.ts +11 -11
- package/src/edit-common.ts +66 -15
- package/src/edit-fidelity.ts +108 -0
- package/src/grep.ts +6 -5
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +7 -0
- package/src/hashline/resolve.ts +65 -38
- package/src/insert.ts +18 -33
- 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 +8 -13
- package/src/utils.ts +28 -14
- package/prompts/grep-guidelines.md +0 -2
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/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
|
@@ -96,3 +96,10 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
|
|
|
96
96
|
export function parseText(edit: string[]): string[] {
|
|
97
97
|
return parseTextWithSeparators(edit).lines;
|
|
98
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
|
+
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, clipLine, decodeStringArray, assertNoNul } from "../utils";
|
|
2
|
-
import { parseHashRef, parseTextWithSeparators, type Anchor } from "./parse";
|
|
2
|
+
import { parseHashRef, parsePayloadText, parseTextWithSeparators, type Anchor, type ParsedText } from "./parse";
|
|
3
3
|
import { HASH_SEP, stripRowPrefix, lineChecksum, type RowPrefixKind } from "./hash";
|
|
4
4
|
import { HASH_RUN } from "./alphabet";
|
|
5
|
-
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
|
+
import { NEW_CONTENT_NOT_ARRAY_MSG, NEW_CONTENT_NOT_STRING_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
6
6
|
import type { LineEnding } from "../normalize";
|
|
7
7
|
|
|
8
8
|
export type RAnchor = {
|
|
@@ -38,6 +38,12 @@ export type HTEdit = {
|
|
|
38
38
|
remove_to: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
export type HTPayloadEdit = {
|
|
42
|
+
replacement_lines: string;
|
|
43
|
+
remove_from: string;
|
|
44
|
+
remove_to: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
41
47
|
function resAnchorFromMap(
|
|
42
48
|
ref: Anchor,
|
|
43
49
|
hashIndex: Map<string, number[]>,
|
|
@@ -116,30 +122,57 @@ export function fmtMismatchWithHashes(
|
|
|
116
122
|
|
|
117
123
|
const ITEM_KS = new Set(["replacement_lines", "remove_from", "remove_to"]);
|
|
118
124
|
|
|
125
|
+
function assertBounds(edit: Record<string, unknown>): void {
|
|
126
|
+
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if ("remove_to" in edit && typeof edit.remove_to !== "string") {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
119
143
|
function assertItem(edit: Record<string, unknown>): void {
|
|
120
|
-
|
|
144
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
|
|
145
|
+
assertBounds(edit);
|
|
146
|
+
if (!("replacement_lines" in edit)) {
|
|
147
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
|
|
150
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
121
153
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
function assertPayloadItem(edit: Record<string, unknown>): void {
|
|
155
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
|
|
156
|
+
assertBounds(edit);
|
|
157
|
+
if (!("replacement_lines" in edit)) {
|
|
158
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" string (use "" to delete).`);
|
|
159
|
+
}
|
|
160
|
+
if (typeof edit.replacement_lines !== "string") {
|
|
161
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveParsedEdit(removeFrom: string, removeTo: string, parsed: ParsedText, warnings?: string[]): HEdit {
|
|
166
|
+
const replaceLines = parsed.lines;
|
|
167
|
+
assertNoNul(replaceLines);
|
|
168
|
+
const bounds = [removeFrom, removeTo].map((ref) => {
|
|
169
|
+
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
170
|
+
}) as [string, string];
|
|
171
|
+
return {
|
|
172
|
+
content_lines: replaceLines,
|
|
173
|
+
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
174
|
+
...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
|
|
175
|
+
};
|
|
143
176
|
}
|
|
144
177
|
|
|
145
178
|
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
@@ -161,20 +194,14 @@ export function stripAnchorRow(
|
|
|
161
194
|
return match[2]!;
|
|
162
195
|
}
|
|
163
196
|
|
|
164
|
-
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}) as [string, string];
|
|
173
|
-
return {
|
|
174
|
-
content_lines: replaceLines,
|
|
175
|
-
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
176
|
-
...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
|
|
177
|
-
};
|
|
197
|
+
export function resEdit(edit: HTEdit | HTPayloadEdit, warnings?: string[]): HEdit {
|
|
198
|
+
if (typeof edit.replacement_lines === "string") {
|
|
199
|
+
assertPayloadItem(edit as unknown as Record<string, unknown>);
|
|
200
|
+
return resolveParsedEdit(edit.remove_from, edit.remove_to, parsePayloadText(edit.replacement_lines), warnings);
|
|
201
|
+
}
|
|
202
|
+
assertItem(edit as Record<string, unknown>);
|
|
203
|
+
const parsed = parseTextWithSeparators(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
|
|
204
|
+
return resolveParsedEdit(edit.remove_from, edit.remove_to, parsed, warnings);
|
|
178
205
|
}
|
|
179
206
|
|
|
180
207
|
function warnUnicodeEsc(
|
package/src/insert.ts
CHANGED
|
@@ -1,43 +1,38 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { execPipeline, type
|
|
4
|
+
import { execPipeline, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
6
|
import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { MAX_HASH_LINES, parseHashRef,
|
|
8
|
+
import { MAX_HASH_LINES, parseHashRef, parsePayloadText, resEdit, resolveAnchorLine, type Anchor, type HEdit, type HTEdit, type StripWarningLocation } from "./hashline";
|
|
9
9
|
import type { LineEnding } from "./normalize";
|
|
10
10
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
11
|
import { withAnchorSession } from "./anchor-registry";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
|
-
import {
|
|
14
|
+
import { isRec, literalEscapeHint, splitLines } from "./utils";
|
|
15
15
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
16
16
|
import type { RPreview, RRState } from "./replace-render";
|
|
17
17
|
export { assertInsertReq, type InsertReq };
|
|
18
18
|
|
|
19
19
|
const insertAnchorSchema = Type.String({
|
|
20
20
|
description:
|
|
21
|
-
|
|
21
|
+
"4-char anchor of the line to insert next to (never the row content). A pasted diff row is stripped with a warning; the anchor line is preserved.",
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
const insertDirectionSchema = Type.Union(
|
|
25
25
|
[Type.Literal("after"), Type.Literal("before")],
|
|
26
26
|
{ description: '"after" or "before"' },
|
|
27
27
|
);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}),
|
|
33
|
-
{
|
|
34
|
-
description: 'One string per line; [""] is a blank line; never include the anchor line.',
|
|
35
|
-
}
|
|
36
|
-
);
|
|
28
|
+
const insertLinesSchema = Type.String({
|
|
29
|
+
description:
|
|
30
|
+
'The exact text to insert. "" inserts nothing, "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line. Never include the anchor line.',
|
|
31
|
+
});
|
|
37
32
|
|
|
38
33
|
const insertPathRequiredSchema = Type.String({
|
|
39
34
|
description:
|
|
40
|
-
"Path to the file the anchor was served for; required and must match anchor ownership.
|
|
35
|
+
"Path to the file the anchor was served for; required and must match anchor ownership.",
|
|
41
36
|
});
|
|
42
37
|
|
|
43
38
|
const insertToolSchema = Type.Object(
|
|
@@ -74,12 +69,12 @@ export function buildInsertEdit(
|
|
|
74
69
|
preload: NormFile,
|
|
75
70
|
ref: Anchor,
|
|
76
71
|
path: string,
|
|
77
|
-
): { editParams:
|
|
78
|
-
const parsed =
|
|
72
|
+
): { editParams: HTEdit; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
|
|
73
|
+
const parsed = parsePayloadText(req.lines);
|
|
79
74
|
const fileLines = splitLines(preload.normalized);
|
|
80
75
|
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, path);
|
|
81
76
|
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
82
|
-
const editParams:
|
|
77
|
+
const editParams: HTEdit = {
|
|
83
78
|
remove_from: ref.hash,
|
|
84
79
|
remove_to: ref.hash,
|
|
85
80
|
replacement_lines:
|
|
@@ -104,15 +99,10 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
|
|
|
104
99
|
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
105
100
|
try {
|
|
106
101
|
const normalized = normReq(request);
|
|
107
|
-
const previewFixes: string[] = [];
|
|
108
|
-
if (isRec(normalized)) {
|
|
109
|
-
const expanded = decodeStringArray(normalized.lines, previewFixes, "lines");
|
|
110
|
-
if (expanded) normalized.lines = expanded;
|
|
111
|
-
}
|
|
112
102
|
assertInsertReq(normalized);
|
|
113
103
|
const previewReq = normalized as InsertReq;
|
|
114
104
|
const { ref, warnings: previewAnchorWarnings } = parseInsertAnchor(previewReq.anchor);
|
|
115
|
-
await throwIfStrictInput(
|
|
105
|
+
await throwIfStrictInput(previewAnchorWarnings);
|
|
116
106
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
117
107
|
anchor: previewReq.anchor,
|
|
118
108
|
providedPath: previewReq.path,
|
|
@@ -141,7 +131,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
141
131
|
}
|
|
142
132
|
}
|
|
143
133
|
|
|
144
|
-
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string
|
|
134
|
+
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string } | null {
|
|
145
135
|
let normalized: unknown;
|
|
146
136
|
try {
|
|
147
137
|
normalized = normReq(args);
|
|
@@ -152,8 +142,7 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
|
|
|
152
142
|
if (
|
|
153
143
|
typeof normalized.anchor !== "string" ||
|
|
154
144
|
(normalized.direction !== "before" && normalized.direction !== "after") ||
|
|
155
|
-
|
|
156
|
-
normalized.lines.some((line) => typeof line !== "string")
|
|
145
|
+
typeof normalized.lines !== "string"
|
|
157
146
|
) {
|
|
158
147
|
return null;
|
|
159
148
|
}
|
|
@@ -161,7 +150,7 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
|
|
|
161
150
|
...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
|
|
162
151
|
anchor: normalized.anchor as string,
|
|
163
152
|
direction: normalized.direction as "before" | "after",
|
|
164
|
-
lines: normalized.lines
|
|
153
|
+
lines: normalized.lines,
|
|
165
154
|
};
|
|
166
155
|
}
|
|
167
156
|
|
|
@@ -186,14 +175,10 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
186
175
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
187
176
|
return withAnchorSession(ctx, async () => {
|
|
188
177
|
const canonical = normReq(params);
|
|
189
|
-
const insertWarnings: string[] = [];
|
|
190
|
-
if (isRec(canonical)) {
|
|
191
|
-
const expanded = decodeStringArray(canonical.lines, insertWarnings, "lines");
|
|
192
|
-
if (expanded) canonical.lines = expanded;
|
|
193
|
-
}
|
|
194
178
|
assertInsertReq(canonical);
|
|
195
179
|
const req = canonical;
|
|
196
|
-
const
|
|
180
|
+
const insertWarnings: string[] = [];
|
|
181
|
+
const literalEscape = literalEscapeHint([req.lines], "lines");
|
|
197
182
|
if (literalEscape !== undefined) insertWarnings.push(literalEscape);
|
|
198
183
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
199
184
|
anchor: req.anchor,
|