pi-hashline-edit-pro 4.4.4 → 4.5.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/src/paths.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { isAbsolute, relative, resolve as resolvePath, join, dirname } from "node:path";
2
+ import { isAbsolute, relative, resolve as resolvePath, join, dirname, parse } from "node:path";
3
3
 
4
4
 
5
5
  function homeBase(): string {
@@ -55,6 +55,22 @@ export function toCwd(filePath: string, cwd: string): string {
55
55
  return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
56
56
  }
57
57
 
58
+ function pathDepth(path: string): number {
59
+ const root = parse(path).root;
60
+ return path.slice(root.length).split(/[\\/]+/).filter((part) => part.length > 0).length;
61
+ }
62
+
63
+ function climbsToRoot(cwd: string, relativePath: string): boolean {
64
+ let climbs = 0;
65
+ for (const part of relativePath.split("/")) {
66
+ if (part !== "..") break;
67
+ climbs += 1;
68
+ }
69
+ return climbs > 0 && climbs >= pathDepth(resolvePath(cwd));
70
+ }
71
+
58
72
  export function toDisplayPath(cwd: string, absolutePath: string, fallback?: string): string {
59
- return relative(cwd, absolutePath).replace(/\\/g, "/") || (fallback ?? absolutePath);
73
+ const rel = relative(cwd, absolutePath).replace(/\\/g, "/");
74
+ if (rel.length === 0 || climbsToRoot(cwd, rel)) return fallback ?? absolutePath;
75
+ return rel;
60
76
  }
@@ -4,7 +4,7 @@ import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./uti
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
6
6
  description:
7
- "One replacement line; never embed \\n inside an element.",
7
+ "One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings; escape sequences are not decoded.",
8
8
  }),
9
9
  {
10
10
  description:
@@ -131,3 +131,31 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
131
131
  }
132
132
  assertNoNul(request.lines);
133
133
  }
134
+
135
+ const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "insert_after"]);
136
+
137
+ export interface TransferReq {
138
+ path?: string;
139
+ source_from: string;
140
+ source_to: string;
141
+ insert_after: string;
142
+ }
143
+
144
+ export function assertTransferReq(request: unknown): asserts request is TransferReq {
145
+ if (!isRec(request)) {
146
+ throw new Error("[E_BAD_SHAPE] Copy/move request must be an object.");
147
+ }
148
+ rejectUnknownFields(request, TRANSFER_KEYS, "Copy/move request");
149
+ if (request.path !== undefined && typeof request.path !== "string") {
150
+ throw new Error('[E_BAD_SHAPE] Copy/move request field "path" must be a string when provided.');
151
+ }
152
+ if (typeof request.source_from !== "string" || request.source_from.length === 0) {
153
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_from" string (4-char anchor from read output).');
154
+ }
155
+ if (typeof request.source_to !== "string" || request.source_to.length === 0) {
156
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (4-char anchor from read output).');
157
+ }
158
+ if (typeof request.insert_after !== "string" || request.insert_after.length === 0) {
159
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires an "insert_after" string (4-char anchor from read output).');
160
+ }
161
+ }
@@ -81,7 +81,7 @@ export function highlightBatchRefs(text: string, theme: FgT): string {
81
81
  }
82
82
 
83
83
  export function fmtCall(
84
- args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | undefined,
84
+ args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string } | undefined,
85
85
  state: RRState,
86
86
  expanded: boolean,
87
87
  theme: CallT,
@@ -89,7 +89,7 @@ export function fmtCall(
89
89
  ): string {
90
90
  const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
91
91
  const path = args?.path ?? state.resolvedPath ?? previewPath;
92
- const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : undefined;
92
+ const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.source_from === "string" && typeof args?.insert_after === "string" ? `${args.source_from}→${args.insert_after}` : undefined;
93
93
  const pathDisplay =
94
94
  typeof path === "string" && path.length > 0
95
95
  ? theme.fg("accent", path)
@@ -264,9 +264,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
264
264
  export function makeRenderCall(
265
265
  preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
266
266
  options: {
267
- getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | null;
267
+ getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string } | null;
268
268
  toolName?: string;
269
- resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string }) => string | undefined;
269
+ resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string }) => string | undefined;
270
270
  } = {},
271
271
  ) {
272
272
  const getInput = options.getInput ?? getPreviewInput;
@@ -9,6 +9,7 @@ import { lineChecksum } from "./hashline";
9
9
  import { freeAnchors, adoptAnchors, withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
10
10
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
11
11
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
12
+ import { joinSeparators } from "./line-endings";
12
13
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
13
14
  import { getDiffContextLines } from "./config";
14
15
  import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
@@ -22,8 +23,10 @@ export interface UndoEntry {
22
23
  content: string;
23
24
  bom: string;
24
25
  originalEnding: LineEnding;
26
+ separators?: LineEnding[];
25
27
  hashes: string[];
26
28
  resultContent: string;
29
+ resultSeparators?: LineEnding[];
27
30
  mode?: number;
28
31
  }
29
32
 
@@ -44,8 +47,10 @@ export async function saveUndo(
44
47
  content: entry.content,
45
48
  bom: entry.bom,
46
49
  ending: entry.originalEnding,
50
+ ...(entry.separators !== undefined ? { separators: entry.separators } : {}),
47
51
  hashes: entry.hashes,
48
52
  resultContent: entry.resultContent,
53
+ ...(entry.resultSeparators !== undefined ? { resultSeparators: entry.resultSeparators } : {}),
49
54
  ...(mode !== undefined ? { mode } : {}),
50
55
  });
51
56
  } catch (error) {
@@ -80,8 +85,10 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
80
85
  content: record.content,
81
86
  bom: record.bom,
82
87
  originalEnding,
88
+ ...(record.separators !== undefined ? { separators: record.separators as LineEnding[] } : {}),
83
89
  hashes: record.hashes,
84
90
  resultContent: record.resultContent,
91
+ ...(record.resultSeparators !== undefined ? { resultSeparators: record.resultSeparators as LineEnding[] } : {}),
85
92
  ...(record.mode !== undefined ? { mode: record.mode } : {}),
86
93
  };
87
94
  } catch (error) {
@@ -171,7 +178,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
171
178
 
172
179
  if (
173
180
  currentRaw !== undefined &&
174
- currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)
181
+ currentRaw !== undo.bom + (undo.resultSeparators !== undefined ? joinSeparators(undo.resultContent, undo.resultSeparators) : restoreEndings(undo.resultContent, undo.originalEnding))
175
182
  ) {
176
183
  return {
177
184
  content: [
@@ -187,7 +194,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
187
194
 
188
195
  await writeAtomic(
189
196
  mutationTargetPath,
190
- undo.bom + restoreEndings(undo.content, undo.originalEnding),
197
+ undo.bom + (undo.separators !== undefined ? joinSeparators(undo.content, undo.separators) : restoreEndings(undo.content, undo.originalEnding)),
191
198
  currentIdentity,
192
199
  undo.mode ?? fallbackFileMode(),
193
200
  );
package/src/replace.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "./replace-diff";
11
11
  import { readNormFile, type NormFile } from "./file-reader";
12
12
  import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
13
- import { splitLines } from "./utils";
13
+ import { literalEscapeWarning, splitLines } from "./utils";
14
14
  import { loadP, loadGuide } from "./prompts";
15
15
  import { type FileIdentity } from "./fs-write";
16
16
  import { applyEdit,
@@ -21,6 +21,7 @@ import { applyEdit,
21
21
  AnchorMismatchError,
22
22
  type HEdit,
23
23
  type NEdit,
24
+ type StripWarningLocation,
24
25
  } from "./hashline";
25
26
  import { type RMetrics } from "./replace-response";
26
27
  import {
@@ -56,6 +57,7 @@ export interface PipelineResult {
56
57
  result: string;
57
58
  bom: string;
58
59
  originalEnding: LineEnding;
60
+ originalSeparators: LineEnding[];
59
61
  hadUtf8DecodeErrors: boolean;
60
62
  warnings: string[];
61
63
  noopEdit?: NEdit;
@@ -67,6 +69,7 @@ export interface PipelineResult {
67
69
  totalRemovedLines: number;
68
70
  identity: FileIdentity;
69
71
  spans?: DiffSpan[];
72
+ contentSeparators?: (LineEnding | undefined)[];
70
73
  }
71
74
 
72
75
 
@@ -76,6 +79,9 @@ export interface ExecPipelineOptions {
76
79
  store?: HashStore;
77
80
  noPersist?: boolean;
78
81
  preloadedNorm?: NormFile;
82
+ served?: ReadonlyMap<string, string>;
83
+ allowEmpty?: boolean;
84
+ stripWarning?: StripWarningLocation;
79
85
  }
80
86
 
81
87
  export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
@@ -91,7 +97,7 @@ export function spanForEdit(originalHashes: string[], from: string, to: string,
91
97
  const replacementCount = splitLines(resultContent).length - (originalHashes.length - (span[1] - span[0] + 1));
92
98
  return { start: span[0], end: span[1], replacementCount };
93
99
  }
94
- async function noteAnchorError(absolutePath: string, error: unknown, noPersist?: boolean): Promise<void> {
100
+ export async function noteAnchorError(absolutePath: string, error: unknown, noPersist?: boolean): Promise<void> {
95
101
  if (noPersist === true) return;
96
102
  if (error instanceof RangeStaleError) {
97
103
  adoptAnchors(absolutePath, error.rangeServedMap);
@@ -137,8 +143,8 @@ export async function execPipeline(
137
143
  const { edit, warnings: editWarnings } = buildReplaceHEdit(params);
138
144
  const hashStore = options?.store ?? await loadHashStore();
139
145
  const preResolvedPath = await resolveTarget(toCwd(targetPath, cwd));
140
- const served = servedForPath(preResolvedPath);
141
- const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
146
+ const served = options?.served ?? servedForPath(preResolvedPath);
147
+ const { normalized: originalNormalized, bom, originalEnding, endingSeparators: originalSeparators, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
142
148
  targetPath, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, allocation: options?.noPersist ? "shadow" : "real", preloadedNorm: options?.preloadedNorm },
143
149
  );
144
150
  const displayPath = toDisplayPath(cwd, absolutePath, targetPath);
@@ -152,6 +158,8 @@ export async function execPipeline(
152
158
  originalHashes,
153
159
  displayPath,
154
160
  served,
161
+ options?.allowEmpty,
162
+ options?.stripWarning,
155
163
  );
156
164
  } catch (error) {
157
165
  await noteAnchorError(absolutePath, error, options?.noPersist);
@@ -181,6 +189,7 @@ export async function execPipeline(
181
189
  result,
182
190
  bom,
183
191
  originalEnding,
192
+ originalSeparators,
184
193
  hadUtf8DecodeErrors,
185
194
  warnings,
186
195
  noopEdit: anchorResult.noopEdit,
@@ -192,6 +201,7 @@ export async function execPipeline(
192
201
  totalRemovedLines,
193
202
  identity,
194
203
  ...(pipeSpans ? { spans: pipeSpans } : {}),
204
+ ...(edit.content_separators !== undefined ? { contentSeparators: edit.content_separators } : {}),
195
205
  };
196
206
  }
197
207
 
@@ -263,6 +273,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
263
273
  const canonical = normReq(params);
264
274
  assertReq(canonical);
265
275
  const normalizedParams = canonical;
276
+ const literalEscape = literalEscapeWarning(normalizedParams.replacement_lines, "replacement_lines");
266
277
  const targetPath = await resolveEditTargetWithRequirement({
267
278
  removeFrom: normalizedParams.remove_from,
268
279
  removeTo: normalizedParams.remove_to,
@@ -287,6 +298,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
287
298
  absolutePath,
288
299
  mutationTargetPath,
289
300
  editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
301
+ prefixWarnings: literalEscape !== undefined ? [literalEscape] : [],
290
302
  signal,
291
303
  });
292
304
  }
@@ -305,7 +317,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
305
317
  cwd: ctx.cwd,
306
318
  signal,
307
319
  hedit: built.edit,
308
- extraWarnings: built.warnings,
320
+ extraWarnings: [...(literalEscape !== undefined ? [literalEscape] : []), ...built.warnings],
309
321
  });
310
322
  });
311
323
  });
package/src/utils.ts CHANGED
@@ -342,10 +342,6 @@ export function decodeStringArray(value: unknown, warnings?: string[], label = "
342
342
  return undefined;
343
343
  }
344
344
 
345
- function splitEditLines(text: string): string[] {
346
- return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
347
- }
348
-
349
345
  function normalizeLineFieldValue(value: unknown): string[] | undefined {
350
346
  const candidate = typeof value === "string"
351
347
  ? value
@@ -356,7 +352,7 @@ function normalizeLineFieldValue(value: unknown): string[] | undefined {
356
352
  const decoded = decodeStringArray(candidate);
357
353
  if (decoded !== undefined) return decoded;
358
354
  if (/^\[\s*\]$/.test(stripTrailingMemberCall(stripCodeFence(candidate)))) return [];
359
- return splitEditLines(candidate);
355
+ return [candidate];
360
356
  }
361
357
 
362
358
  function normalizeEditLines(record: Record<string, unknown>): void {
@@ -366,3 +362,42 @@ function normalizeEditLines(record: Record<string, unknown>): void {
366
362
  if (lines !== undefined) record[key] = lines;
367
363
  }
368
364
  }
365
+
366
+ const LITERAL_ESCAPE_RE = /\\(?:u([0-9a-fA-F]{4})|([ntr"]))/g;
367
+
368
+ const REAL_LINE_BREAK_RE = /[\n\r]/;
369
+
370
+ function isSurrogateEscapePair(line: string, index: number, hex: string): boolean {
371
+ const code = Number.parseInt(hex, 16);
372
+ if (code >= 0xd800 && code <= 0xdbff) {
373
+ const next = /^\\u([0-9a-fA-F]{4})/.exec(line.slice(index + 6))?.[1];
374
+ if (next === undefined) return false;
375
+ const nextCode = Number.parseInt(next, 16);
376
+ return nextCode >= 0xdc00 && nextCode <= 0xdfff;
377
+ }
378
+ if (code >= 0xdc00 && code <= 0xdfff) {
379
+ const previous = /\\u([0-9a-fA-F]{4})$/.exec(line.slice(0, index))?.[1];
380
+ if (previous === undefined) return false;
381
+ const previousCode = Number.parseInt(previous, 16);
382
+ return previousCode >= 0xd800 && previousCode <= 0xdbff;
383
+ }
384
+ return false;
385
+ }
386
+
387
+ export function literalEscapeWarning(lines: string[], label: string): string | undefined {
388
+ for (const line of lines) {
389
+ if (!line.includes("\\")) continue;
390
+ const hasRealBreak = REAL_LINE_BREAK_RE.test(line);
391
+ for (const match of line.matchAll(LITERAL_ESCAPE_RE)) {
392
+ const hex = match[1];
393
+ const simple = match[2];
394
+ if (simple !== undefined && hasRealBreak) continue;
395
+ if (hex !== undefined) {
396
+ if (hex.toLowerCase() === "dddd") continue;
397
+ if (isSurrogateEscapePair(line, match.index, hex)) continue;
398
+ }
399
+ return `[W_LITERAL_ESCAPE] "${label}" contains the literal escape text "${match[0]}"`;
400
+ }
401
+ }
402
+ return undefined;
403
+ }