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.
@@ -0,0 +1,227 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { constants } from "node:fs";
3
+ import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
4
+ import { commitEdit } from "./commit";
5
+ import { readNormFile, type NormFile } from "./file-reader";
6
+ import { fmtRegion, MAX_HASH_LINES, parseHashRef, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
7
+ import { withAnchorSession } from "./anchor-registry";
8
+ import { loadP, loadGuide } from "./prompts";
9
+ import {
10
+ assertReplaceWithinReq,
11
+ buildReplaceWithinToolSchema,
12
+ getReplaceWithinInput,
13
+ normalizeReplaceWithinRequest,
14
+ type ReplaceWithinReq,
15
+ } from "./payload-contract";
16
+ import { literalEscapeHint, splitLines } from "./utils";
17
+ import { toLF } from "./normalize";
18
+ import { MAX_RANGE_STALE_LINES } from "./constants";
19
+ import {
20
+ DEFAULT_EDIT_FLAGS,
21
+ editRenderResultWrapper,
22
+ editToolBase,
23
+ queuedEdit,
24
+ resolveEditTargetWithRequirement,
25
+ throwIfStrictInput,
26
+ tryResolveEditTarget,
27
+ withReplaceWithinPrompts,
28
+ type EditToolFlags,
29
+ } from "./edit-common";
30
+ import { makeRenderCall, type RPreview, type RRState } from "./replace-render";
31
+
32
+ interface WithinRefs {
33
+ from: Anchor;
34
+ to: Anchor;
35
+ }
36
+
37
+ export interface WithinPlan {
38
+ editParams: { remove_from: string; remove_to: string; replacement_lines: string };
39
+ }
40
+
41
+ function formatWithinRange(start: number, end: number): string {
42
+ return start === end ? `line ${start}` : `lines ${start}-${end}`;
43
+ }
44
+
45
+ function offsetLineNumber(rangeLines: string[], offset: number): number {
46
+ let cursor = 0;
47
+ for (let index = 0; index < rangeLines.length; index++) {
48
+ const length = rangeLines[index]!.length;
49
+ if (offset <= cursor + length) return index + 1;
50
+ cursor += length + 1;
51
+ }
52
+ return rangeLines.length;
53
+ }
54
+
55
+ function matchOffsets(text: string, oldText: string): number[] {
56
+ const offsets: number[] = [];
57
+ let from = 0;
58
+ for (;;) {
59
+ const found = text.indexOf(oldText, from);
60
+ if (found < 0) return offsets;
61
+ offsets.push(found);
62
+ from = found + 1;
63
+ }
64
+ }
65
+
66
+ function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[]): string {
67
+ const rangeLength = end - start + 1;
68
+ const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
69
+ const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
70
+ const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}.]` : "";
71
+ return `[E_SUBSTRING_NOT_FOUND] "replace_old" was not found in ${formatWithinRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\nCopy replace_old exactly from the served row (comparison uses LF breaks and excludes the last line's terminator) and retry.`;
72
+ }
73
+
74
+ function ambiguousMessage(displayPath: string, start: number, end: number, matchLines: number[]): string {
75
+ const shownCount = 8;
76
+ const shown = matchLines.slice(0, shownCount).join(", ");
77
+ const more = matchLines.length > shownCount ? ` (+${matchLines.length - shownCount} more)` : "";
78
+ return `[E_SUBSTRING_AMBIGUOUS] "replace_old" occurs ${matchLines.length} times in ${formatWithinRange(start, end)} of ${displayPath} (matching lines ${shown}${more}). Narrow replace_from/replace_to to one line, or extend replace_old so it matches exactly once.`;
79
+ }
80
+
81
+ export function parseWithinAnchors(req: ReplaceWithinReq): { refs: WithinRefs; warnings: string[] } {
82
+ const warnings: string[] = [];
83
+ const from = stripAnchorRow(req.replace_from.trim(), "replace_from entry", warnings);
84
+ const to = stripAnchorRow(req.replace_to.trim(), "replace_to entry", warnings);
85
+ return { refs: { from: parseHashRef(from), to: parseHashRef(to) }, warnings };
86
+ }
87
+
88
+ export function buildReplaceWithinEdit(req: ReplaceWithinReq, refs: WithinRefs, preload: NormFile, displayPath: string): WithinPlan {
89
+ const fileLines = splitLines(preload.normalized);
90
+ const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
91
+ const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
92
+ const start = Math.min(fromLine, toLine);
93
+ const end = Math.max(fromLine, toLine);
94
+ const rangeLines = fileLines.slice(start - 1, end);
95
+ const rangeText = rangeLines.join("\n");
96
+ const oldText = toLF(req.replace_old);
97
+ const offsets = matchOffsets(rangeText, oldText);
98
+ if (offsets.length === 0) {
99
+ throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines));
100
+ }
101
+ if (offsets.length > 1) {
102
+ throw new Error(ambiguousMessage(displayPath, start, end, offsets.map((offset) => start - 1 + offsetLineNumber(rangeLines, offset))));
103
+ }
104
+ const matchOffset = offsets[0]!;
105
+ const replacement = rangeText.slice(0, matchOffset) + req.replace_new + rangeText.slice(matchOffset + oldText.length);
106
+ const startRef = fromLine <= toLine ? refs.from : refs.to;
107
+ const endRef = fromLine <= toLine ? refs.to : refs.from;
108
+ return {
109
+ editParams: {
110
+ remove_from: startRef.hash,
111
+ remove_to: endRef.hash,
112
+ replacement_lines: replacement,
113
+ },
114
+ };
115
+ }
116
+
117
+ export async function replaceWithinPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
118
+ try {
119
+ const normalized = normalizeReplaceWithinRequest(request);
120
+ assertReplaceWithinReq(normalized);
121
+ const req = normalized;
122
+ const { refs, warnings } = parseWithinAnchors(req);
123
+ await throwIfStrictInput(warnings);
124
+ const targetPath = await resolveEditTargetWithRequirement({
125
+ removeFrom: req.replace_from,
126
+ removeTo: req.replace_to,
127
+ providedPath: req.path,
128
+ cwd,
129
+ });
130
+ const preload = await readNormFile(targetPath, cwd, {
131
+ accessMode: constants.R_OK,
132
+ maxLines: MAX_HASH_LINES,
133
+ noPersist: true,
134
+ allocation: "shadow",
135
+ signal,
136
+ });
137
+ const plan = buildReplaceWithinEdit(req, refs, preload, targetPath);
138
+ const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
139
+ accessMode: constants.R_OK,
140
+ noPersist: true,
141
+ preloadedNorm: preload,
142
+ signal,
143
+ });
144
+ return previewFromPipe(pipe);
145
+ } catch (error: unknown) {
146
+ if (signal?.aborted) throw error;
147
+ return previewError(error);
148
+ }
149
+ }
150
+
151
+ type ReplaceWithinToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
152
+
153
+ export function buildReplaceWithinToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ReplaceWithinToolDef {
154
+ const prompted = withReplaceWithinPrompts({
155
+ description: loadP("../prompts/replace-within.md"),
156
+ snippet: loadP("../prompts/replace-within-snippet.md"),
157
+ guidelines: loadGuide("../prompts/replace-within-guidelines.md"),
158
+ }, flags);
159
+ return {
160
+ name: "replace_within",
161
+ label: "Replace Within",
162
+ description: prompted.description,
163
+ promptSnippet: prompted.snippet,
164
+ promptGuidelines: prompted.guidelines,
165
+ ...editToolBase,
166
+ prepareArguments: normalizeReplaceWithinRequest,
167
+ parameters: buildReplaceWithinToolSchema(flags.requirePath),
168
+ renderCall: makeRenderCall(replaceWithinPreview, {
169
+ getInput: getReplaceWithinInput,
170
+ toolName: "replace_within",
171
+ resolveTarget: (input) => (typeof input.replace_from === "string" ? tryResolveEditTarget(input.replace_from, input.replace_to) : undefined),
172
+ }),
173
+ renderResult: editRenderResultWrapper,
174
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
175
+ return withAnchorSession(ctx, async () => {
176
+ const normalized = normalizeReplaceWithinRequest(params);
177
+ assertReplaceWithinReq(normalized);
178
+ const req = normalized;
179
+ const { refs, warnings } = parseWithinAnchors(req);
180
+ await throwIfStrictInput(warnings);
181
+ const hints = [
182
+ literalEscapeHint([req.replace_old], "replace_old"),
183
+ literalEscapeHint([req.replace_new], "replace_new"),
184
+ ].filter((hint): hint is string => hint !== undefined);
185
+ const targetPath = await resolveEditTargetWithRequirement({
186
+ removeFrom: req.replace_from,
187
+ removeTo: req.replace_to,
188
+ providedPath: req.path,
189
+ cwd: ctx.cwd,
190
+ });
191
+ return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
192
+ const preload = await readNormFile(targetPath, ctx.cwd, {
193
+ signal,
194
+ accessMode: constants.R_OK | constants.W_OK,
195
+ maxLines: MAX_HASH_LINES,
196
+ });
197
+ let plan: WithinPlan;
198
+ try {
199
+ plan = buildReplaceWithinEdit(req, refs, preload, targetPath);
200
+ } catch (error) {
201
+ await noteAnchorError(preload.absolutePath, error);
202
+ throw error;
203
+ }
204
+ const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
205
+ accessMode: constants.R_OK | constants.W_OK,
206
+ signal,
207
+ preloadedNorm: preload,
208
+ });
209
+ return commitEdit(pipe, {
210
+ path: pipe.path,
211
+ absolutePath,
212
+ mutationTargetPath,
213
+ editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
214
+ prefixWarnings: [...warnings, ...hints],
215
+ signal,
216
+ verb: "replaced",
217
+ noopNoun: "Replacement",
218
+ });
219
+ });
220
+ });
221
+ },
222
+ };
223
+ }
224
+
225
+ export function regReplaceWithin(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
226
+ pi.registerTool(buildReplaceWithinToolDef(flags));
227
+ }
package/src/replace.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type LineEnding,
10
10
  } from "./replace-diff";
11
11
  import { readNormFile, type NormFile } from "./file-reader";
12
- import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
12
+ import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
13
13
  import { literalEscapeHint, splitLines } from "./utils";
14
14
  import { loadP, loadGuide } from "./prompts";
15
15
  import { type FileIdentity } from "./fs-write";
@@ -83,6 +83,7 @@ export interface ExecPipelineOptions {
83
83
  served?: ReadonlyMap<string, string>;
84
84
  allowEmpty?: boolean;
85
85
  stripWarning?: StripWarningLocation;
86
+ endingOverrides?: (LineEnding | undefined)[];
86
87
  }
87
88
 
88
89
  export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
@@ -120,28 +121,35 @@ function countLineChanges(
120
121
  totalRemovedLines,
121
122
  };
122
123
  }
123
-
124
- export function buildReplaceHEdit(params: ReqParams): { edit: HEdit; warnings: string[] } {
124
+ export function buildReplaceHEdit(params: RawReqParams): { edit: HEdit; warnings: string[] } {
125
125
  const editWarnings: string[] = [];
126
- const edit = resEdit(
127
- {
128
- remove_from: params.remove_from,
129
- remove_to: params.remove_to,
130
- replacement_lines: params.replacement_lines,
131
- },
132
- editWarnings,
133
- );
126
+ const anchors = { remove_from: params.remove_from, remove_to: params.remove_to };
127
+ const edit = typeof params.replacement_lines === "string"
128
+ ? resEdit({ ...anchors, replacement_lines: params.replacement_lines }, editWarnings)
129
+ : resEdit({ ...anchors, replacement_lines: params.replacement_lines }, editWarnings);
134
130
  return { edit, warnings: editWarnings };
135
131
  }
136
132
 
133
+ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[] | undefined): HEdit {
134
+ if (overrides === undefined) return edit;
135
+ const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
136
+ const separators: (LineEnding | undefined)[] = new Array(length);
137
+ for (let index = 0; index < length; index++) {
138
+ separators[index] = overrides[index] ?? edit.content_separators?.[index];
139
+ }
140
+ if (separators.every((ending) => ending === undefined)) return edit;
141
+ return { ...edit, content_separators: separators };
142
+ }
143
+
137
144
  export async function execPipeline(
138
145
  targetPath: string,
139
- params: ReqParams,
146
+ params: RawReqParams,
140
147
  cwd: string,
141
148
  options?: ExecPipelineOptions,
142
149
  ): Promise<PipelineResult> {
143
150
 
144
151
  const { edit, warnings: editWarnings } = buildReplaceHEdit(params);
152
+ const anchoredEdit = withEndingOverrides(edit, options?.endingOverrides);
145
153
  const hashStore = options?.store ?? await loadHashStore();
146
154
  const preResolvedPath = await resolveTarget(toCwd(targetPath, cwd));
147
155
  const served = options?.served ?? servedForPath(preResolvedPath);
@@ -154,7 +162,7 @@ export async function execPipeline(
154
162
  try {
155
163
  anchorResult = applyEdit(
156
164
  originalNormalized,
157
- edit,
165
+ anchoredEdit,
158
166
  options?.signal,
159
167
  originalHashes,
160
168
  displayPath,
@@ -202,12 +210,15 @@ export async function execPipeline(
202
210
  totalRemovedLines,
203
211
  identity,
204
212
  ...(pipeSpans ? { spans: pipeSpans } : {}),
205
- ...(edit.content_separators !== undefined ? { contentSeparators: edit.content_separators } : {}),
213
+ ...(anchoredEdit.content_separators !== undefined ? { contentSeparators: anchoredEdit.content_separators } : {}),
206
214
  };
207
215
  }
208
216
 
209
217
  export function previewFromPipe(pipe: PipelineResult): RPreview {
210
218
  if (pipe.originalNormalized === pipe.result) {
219
+ if (pipe.contentSeparators !== undefined) {
220
+ return { diff: "", path: pipe.path };
221
+ }
211
222
  return {
212
223
  error: `No changes made to ${pipe.path}. The edit produced identical content.`,
213
224
  path: pipe.path,
@@ -274,7 +285,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
274
285
  const canonical = normReq(params);
275
286
  assertReq(canonical);
276
287
  const normalizedParams = canonical;
277
- const literalEscape = literalEscapeHint(normalizedParams.replacement_lines, "replacement_lines");
288
+ const literalEscape = literalEscapeHint([normalizedParams.replacement_lines], "replacement_lines");
278
289
  const targetPath = await resolveEditTargetWithRequirement({
279
290
  removeFrom: normalizedParams.remove_from,
280
291
  removeTo: normalizedParams.remove_to,
package/src/utils.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { NUL_CONTENT_MSG, MAX_BYTES } from "./constants";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
+ import { splitWithEndings } from "./line-endings";
4
+ import type { LineEnding } from "./normalize";
3
5
 
4
6
  export function isRec(value: unknown): value is Record<string, unknown> {
5
7
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -342,24 +344,36 @@ export function decodeStringArray(value: unknown, warnings?: string[], label = "
342
344
  return undefined;
343
345
  }
344
346
 
345
- function normalizeLineFieldValue(value: unknown): string[] | undefined {
346
- const candidate = typeof value === "string"
347
- ? value
348
- : Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
349
- ? value[0]
350
- : undefined;
351
- if (candidate === undefined) return undefined;
352
- const decoded = decodeStringArray(candidate);
353
- if (decoded !== undefined) return decoded;
354
- if (/^\[\s*\]$/.test(stripTrailingMemberCall(stripCodeFence(candidate)))) return [];
355
- return [candidate];
347
+ function legacyLinesToText(lines: string[]): string {
348
+ const decoded = decodeStringArray(lines) ?? lines;
349
+ const parsedLines: string[] = [];
350
+ const parsedSeparators: (LineEnding | undefined)[] = [];
351
+ for (const element of decoded) {
352
+ const parsed = splitWithEndings(element);
353
+ const lineCount = element.endsWith("\n") || element.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
354
+ for (let index = 0; index < lineCount; index++) {
355
+ parsedLines.push(parsed.lines[index]!);
356
+ parsedSeparators.push(parsed.endings[index]);
357
+ }
358
+ }
359
+ let text = "";
360
+ for (let index = 0; index < parsedLines.length; index++) {
361
+ const line = parsedLines[index]!;
362
+ const separator = parsedSeparators[index];
363
+ text += line;
364
+ if (separator !== undefined) text += separator;
365
+ else if (index < parsedLines.length - 1) text += "\n";
366
+ else if (line === "") text += "\n";
367
+ }
368
+ return text;
356
369
  }
357
370
 
358
371
  function normalizeEditLines(record: Record<string, unknown>): void {
359
372
  for (const key of ["replacement_lines", "lines"]) {
360
- if (!(key in record)) continue;
361
- const lines = normalizeLineFieldValue(record[key]);
362
- if (lines !== undefined) record[key] = lines;
373
+ const value = record[key];
374
+ if (Array.isArray(value) && value.every((entry): entry is string => typeof entry === "string")) {
375
+ record[key] = legacyLinesToText(value);
376
+ }
363
377
  }
364
378
  }
365
379
 
@@ -1,2 +0,0 @@
1
- - `anchor_grep`: hits and `context` rows come back as `lineNumber │ anchor│content` — target them with `replace`/`insert` directly; `lineNumber` enables jump-to-line.
2
- - `anchor_grep`: binary/image files are skipped silently.