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.
@@ -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
- if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
95
- const guidelines = base.guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
96
- return { description: base.description, snippet: base.snippet, guidelines };
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, toolName: "copy" | "move"): { description: string; snippet: string; guidelines: string[] } {
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, loadGuide } from "./prompts";
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: loadP("../prompts/grep.md"),
537
- promptSnippet: loadP("../prompts/grep-snippet.md"),
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",
@@ -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,
@@ -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
+ }
@@ -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
- rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
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
- if ("remove_from" in edit && typeof edit.remove_from !== "string") {
123
- throw new Error(
124
- `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
125
- );
126
- }
127
- if ("remove_to" in edit && typeof edit.remove_to !== "string") {
128
- throw new Error(
129
- `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
130
- );
131
- }
132
- if (!("replacement_lines" in edit)) {
133
- throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
134
- }
135
- if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
136
- throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
137
- }
138
- if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
139
- throw new Error(
140
- `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
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
- assertItem(edit as Record<string, unknown>);
166
-
167
- const parsed = parseTextWithSeparators(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
168
- const replaceLines = parsed.lines;
169
- assertNoNul(replaceLines);
170
- const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
171
- return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
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 ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
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, parseTextWithSeparators, resEdit, resolveAnchorLine, type Anchor, type HEdit, type StripWarningLocation } from "./hashline";
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 { decodeStringArray, isRec, literalEscapeHint, splitLines } from "./utils";
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
- 'Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. A pasted diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
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
- const insertLinesSchema = Type.Array(
30
- Type.String({
31
- description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
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. The anchor still resolves the target.",
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: ReqParams; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
78
- const parsed = parseTextWithSeparators(req.lines);
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: ReqParams = {
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([...previewFixes, ...previewAnchorWarnings]);
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[] } | null {
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
- !Array.isArray(normalized.lines) ||
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 as string[],
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 literalEscape = literalEscapeHint(req.lines, "lines");
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,