pi-hashline-edit-pro 4.4.3 → 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.
Files changed (43) hide show
  1. package/README.md +312 -63
  2. package/index.ts +22 -6
  3. package/package.json +4 -3
  4. package/prompts/copy-guidelines.md +1 -0
  5. package/prompts/copy-snippet.md +1 -0
  6. package/prompts/copy.md +3 -0
  7. package/prompts/grep.md +1 -1
  8. package/prompts/insert.md +1 -1
  9. package/prompts/move-guidelines.md +1 -0
  10. package/prompts/move-snippet.md +1 -0
  11. package/prompts/move.md +3 -0
  12. package/prompts/read-guidelines.md +2 -1
  13. package/prompts/replace-guidelines.md +0 -2
  14. package/prompts/replace.md +1 -1
  15. package/prompts/undo-last-change-guidelines.md +2 -1
  16. package/prompts/undo-last-change-snippet.md +1 -1
  17. package/prompts/undo-last-change.md +1 -1
  18. package/src/anchor-registry.ts +128 -39
  19. package/src/auto-read-all.ts +3 -1
  20. package/src/batch.ts +35 -7
  21. package/src/commit.ts +22 -4
  22. package/src/config-ui.ts +2 -1
  23. package/src/config.ts +6 -1
  24. package/src/constants.ts +4 -1
  25. package/src/copy-move.ts +793 -0
  26. package/src/edit-common.ts +21 -1
  27. package/src/file-reader.ts +4 -0
  28. package/src/grep.ts +3 -1
  29. package/src/hash-store/validation.ts +22 -0
  30. package/src/hash-store.ts +33 -5
  31. package/src/hashline/apply.ts +8 -3
  32. package/src/hashline/index.ts +3 -0
  33. package/src/hashline/parse.ts +22 -10
  34. package/src/hashline/resolve.ts +23 -8
  35. package/src/insert.ts +28 -10
  36. package/src/line-endings.ts +151 -0
  37. package/src/paths.ts +23 -3
  38. package/src/payload-contract.ts +29 -1
  39. package/src/read.ts +7 -5
  40. package/src/replace-render.ts +4 -4
  41. package/src/replace-undo.ts +13 -4
  42. package/src/replace.ts +17 -5
  43. package/src/utils.ts +40 -5
package/src/batch.ts CHANGED
@@ -15,9 +15,11 @@ import {
15
15
  MAX_HASH_LINES,
16
16
  type HEdit,
17
17
  type PlannedEdit,
18
+ type StripWarningLocation,
18
19
  } from "./hashline";
19
- import { adoptAnchors, servedForPath } from "./anchor-registry";
20
- import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
20
+ import { adoptAnchors, servedForPath, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
21
+ import { stripBOM, toLF, type LineEnding } from "./normalize";
22
+ import { applySpanEndings, joinSeparators, separatorsForSpans } from "./line-endings";
21
23
  import { assertInsertReq, assertReq, normReq } from "./payload-contract";
22
24
  import { saveUndo } from "./replace-undo";
23
25
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
@@ -44,6 +46,7 @@ interface BatchBase {
44
46
  hashes: string[];
45
47
  bom: string;
46
48
  ending: LineEnding;
49
+ separators: LineEnding[];
47
50
  identity: FileIdentity;
48
51
  hadUtf8DecodeErrors: boolean;
49
52
  absolutePath: string;
@@ -64,6 +67,7 @@ export interface BatchPiece {
64
67
  noop: boolean;
65
68
  foldedLines: number;
66
69
  carryIndex?: number;
70
+ separators?: (LineEnding | undefined)[];
67
71
  }
68
72
 
69
73
  export interface BatchMemberInput {
@@ -77,6 +81,8 @@ export interface BatchMemberInput {
77
81
  hedit: HEdit;
78
82
  extraWarnings: string[];
79
83
  foldedLines?: number;
84
+ stripWarning?: StripWarningLocation;
85
+ contentSeparators?: (LineEnding | undefined)[];
80
86
  }
81
87
 
82
88
  interface BatchFailure {
@@ -434,6 +440,7 @@ export async function ensureBatchBase(input: {
434
440
  hashes: file.fileHashes.slice(),
435
441
  bom: file.bom,
436
442
  ending: file.originalEnding,
443
+ separators: file.endingSeparators,
437
444
  identity: file.identity,
438
445
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors,
439
446
  absolutePath: file.absolutePath,
@@ -482,6 +489,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
482
489
  servedHashes: runtime.served,
483
490
  signal: input.signal,
484
491
  baseFileLines: base.baseLines,
492
+ stripWarning: input.stripWarning,
485
493
  });
486
494
  } catch (error) {
487
495
  if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
@@ -497,6 +505,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
497
505
  const originalSlice = baseLines.slice(start - 1, end);
498
506
  const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
499
507
  const foldedLines = input.foldedLines ?? 0;
508
+ const separators = input.contentSeparators ?? input.hedit.content_separators;
500
509
  const carryIndex =
501
510
  input.kind === "insert" && foldedLines > 0
502
511
  ? input.direction === "after"
@@ -513,6 +522,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
513
522
  fromHash: input.hedit.hash_bounds[0].hash,
514
523
  toHash: input.hedit.hash_bounds[1].hash,
515
524
  newLines: [...newLines],
525
+ ...(separators !== undefined ? { separators } : {}),
516
526
  warnings: [...input.extraWarnings, ...planned.warnings],
517
527
  noop,
518
528
  foldedLines,
@@ -569,12 +579,16 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
569
579
  consumed.add(partner);
570
580
  const before = piece.direction === "before" ? piece : partner;
571
581
  const after = piece.direction === "before" ? partner : piece;
582
+ const beforeSeparators = before.separators ?? before.newLines.map(() => undefined);
583
+ const afterSeparators = after.separators ?? after.newLines.map(() => undefined);
584
+ const mergedSeparators = [...beforeSeparators.slice(0, before.newLines.length), ...afterSeparators.slice(1)];
572
585
  merged.push({
573
586
  ...before,
574
587
  order: Math.min(before.order, after.order),
575
588
  newLines: [...before.newLines, ...after.newLines.slice(1)],
576
589
  warnings: [...before.warnings, ...after.warnings],
577
590
  foldedLines: before.foldedLines + after.foldedLines - 1,
591
+ ...(before.separators !== undefined || after.separators !== undefined ? { separators: mergedSeparators } : {}),
578
592
  });
579
593
  }
580
594
  return merged;
@@ -625,19 +639,29 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
625
639
  }
626
640
  }
627
641
  const composed = composeBatchLines(base.content, effectivePieces);
642
+ const spans = pieceMappingSpans(effectivePieces);
643
+ const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending);
644
+ applySpanEndings(resultSeparators, effectivePieces.map((piece) => ({
645
+ start: piece.start - 1,
646
+ end: piece.end - 1,
647
+ replacementCount: piece.newLines.length,
648
+ ...(piece.separators !== undefined ? { endings: piece.separators } : {}),
649
+ })));
628
650
  const warnings = [...runtime.warnings];
629
651
  if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
630
652
  try {
631
653
  await throwIfStrictInput(dedupeWarnings(warnings));
632
654
  assertNotEmpty(base.content, composed);
633
655
  assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
634
- const finalBytes = base.bom + restoreEndings(composed, base.ending);
656
+ const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
635
657
  assertByteLimit(finalBytes, paths.displayPath);
636
658
  } catch (error) {
637
659
  discardBatchState(runtime);
638
660
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
639
661
  throw error;
640
662
  }
663
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
664
+ if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
641
665
  if (composed === base.content) {
642
666
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
643
667
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
@@ -655,12 +679,11 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
655
679
  discardBatchState(runtime);
656
680
  throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
657
681
  }
658
- const preflightSpans = pieceMappingSpans(effectivePieces);
659
682
  try {
660
683
  await lineHashes(composed, runtime.target, {
661
684
  content: base.content,
662
685
  hashes: base.hashes,
663
- spans: preflightSpans,
686
+ spans,
664
687
  }, undefined, false, true);
665
688
  } catch (error) {
666
689
  discardBatchState(runtime);
@@ -671,8 +694,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
671
694
  content: base.content,
672
695
  bom: base.bom,
673
696
  originalEnding: base.ending,
697
+ separators: base.separators,
674
698
  hashes: base.hashes,
675
699
  resultContent: composed,
700
+ resultSeparators,
676
701
  });
677
702
  if (!undo.persisted) {
678
703
  discardBatchState(runtime);
@@ -680,7 +705,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
680
705
  }
681
706
  try {
682
707
  abortIf(signal);
683
- await writeAtomic(paths.absolutePath, base.bom + restoreEndings(composed, base.ending), base.identity);
708
+ await writeAtomic(paths.absolutePath, base.bom + joinSeparators(composed, resultSeparators), base.identity);
684
709
  } catch (error) {
685
710
  await undo.restore();
686
711
  discardBatchState(runtime);
@@ -688,7 +713,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
688
713
  throw error;
689
714
  }
690
715
  const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
691
- const spans = pieceMappingSpans(effectivePieces);
692
716
  let resultHashes: string[];
693
717
  try {
694
718
  resultHashes = await lineHashes(composed, runtime.target, {
@@ -700,6 +724,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
700
724
  const detail = error instanceof Error ? error.message : String(error);
701
725
  throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
702
726
  }
727
+ const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
728
+ if (writeReclaim !== undefined) warnings.push(writeReclaim);
703
729
  const range = changedRange(base.content, composed);
704
730
  let added = 0;
705
731
  let removed = 0;
@@ -746,6 +772,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
746
772
  async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
747
773
  const executed = runtime.applied + runtime.noops;
748
774
  const warnings = [...runtime.warnings];
775
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
776
+ if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
749
777
  const noop = buildNoop(
750
778
  {
751
779
  path,
package/src/commit.ts CHANGED
@@ -6,10 +6,12 @@ import { saveUndo } from "./replace-undo";
6
6
  import { getDiffContextLines } from "./config";
7
7
  import { safeSnapId } from "./file-reader";
8
8
  import { writeAtomic } from "./fs-write";
9
+ import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
9
10
  import { servedHashesFromDiff, serveRows } from "./served";
10
11
  import { lineHashes } from "./hashline";
11
12
  import { spanForEdit } from "./replace";
12
- import { restoreEndings, stripBOM, toLF } from "./normalize";
13
+ import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
14
+ import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
13
15
  export interface CommitMeta {
14
16
  editAnchors?: [string, string];
15
17
  anchorCarry?: number;
@@ -21,12 +23,15 @@ export interface CommitMeta {
21
23
  noopNoun?: string;
22
24
  prefixWarnings?: string[];
23
25
  foldedAnchorLines?: number;
26
+ endingOverrides?: (LineEnding | undefined)[];
24
27
  }
25
28
 
26
29
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
27
30
  const { path, absolutePath, mutationTargetPath, signal } = meta;
28
31
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
29
32
  const editsAttempted = 1;
33
+ const readReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
34
+ if (readReclaim !== undefined) warnings.push(readReclaim);
30
35
 
31
36
  if (pipe.result === pipe.originalNormalized) {
32
37
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
@@ -47,7 +52,18 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
47
52
  );
48
53
  }
49
54
 
50
- const finalFileBytes = pipe.bom + restoreEndings(pipe.result, pipe.originalEnding);
55
+ const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
56
+ if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
57
+ const resultSeparators = span
58
+ ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
59
+ : undefined;
60
+ if (resultSeparators !== undefined && span !== undefined) {
61
+ applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
62
+ applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
63
+ }
64
+ const finalFileBytes = pipe.bom + (resultSeparators !== undefined
65
+ ? joinSeparators(pipe.result, resultSeparators)
66
+ : restoreEndings(pipe.result, pipe.originalEnding));
51
67
  assertByteLimit(finalFileBytes, path);
52
68
 
53
69
  if (pipe.hadUtf8DecodeErrors) {
@@ -77,8 +93,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
77
93
  content: pipe.originalNormalized,
78
94
  bom: pipe.bom,
79
95
  originalEnding: pipe.originalEnding,
96
+ separators: pipe.originalSeparators,
80
97
  hashes: pipe.originalHashes,
81
98
  resultContent: pipe.result,
99
+ ...(resultSeparators !== undefined ? { resultSeparators } : {}),
82
100
  });
83
101
  if (!undo.persisted) {
84
102
  throw new Error(
@@ -107,8 +125,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
107
125
  removedLines: pipe.totalRemovedLines,
108
126
  };
109
127
 
110
- const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
111
- if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
112
128
  let resultHashes: string[];
113
129
  try {
114
130
  resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
@@ -120,6 +136,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
120
136
  const detail = error instanceof Error ? error.message : String(error);
121
137
  throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
122
138
  }
139
+ const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
140
+ if (writeReclaim !== undefined) warnings.push(writeReclaim);
123
141
  const successInput = {
124
142
  path,
125
143
  originalNormalized: pipe.originalNormalized,
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -23,6 +23,7 @@ export function configRows(config: Config): ConfigRow[] {
23
23
  { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
24
24
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
+ { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
26
27
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
27
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
28
29
  ];
package/src/config.ts CHANGED
@@ -13,6 +13,7 @@ export const MAX_DIFF_CONTEXT_LINES = 10;
13
13
  export interface Config {
14
14
  autoRead: boolean;
15
15
  anchorGrepEnabled: boolean;
16
+ copyMoveEnabled?: boolean;
16
17
  autoReadAll?: AutoReadAllMode;
17
18
  autoReadAllIgnore?: string[];
18
19
  requirePath?: boolean;
@@ -23,6 +24,7 @@ export interface Config {
23
24
  const DEFAULT_CONFIG: Config = {
24
25
  autoRead: true,
25
26
  anchorGrepEnabled: true,
27
+ copyMoveEnabled: true,
26
28
  autoReadAll: "off",
27
29
  autoReadAllIgnore: [],
28
30
  requirePath: false,
@@ -72,6 +74,7 @@ function parseConfig(content: string): Config {
72
74
  }
73
75
  const autoRead = parsed.autoRead;
74
76
  const anchorGrepEnabled = parsed.anchorGrepEnabled;
77
+ const copyMoveEnabled = parsed.copyMoveEnabled;
75
78
  const autoReadAll = parsed.autoReadAll;
76
79
  const requirePath = parsed.requirePath;
77
80
  const strictInput = parsed.strictInput;
@@ -80,6 +83,7 @@ function parseConfig(content: string): Config {
80
83
  return {
81
84
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
82
85
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
86
+ copyMoveEnabled: typeof copyMoveEnabled === "boolean" ? copyMoveEnabled : DEFAULT_CONFIG.copyMoveEnabled,
83
87
  autoReadAll: parseAutoReadAllMode(autoReadAll),
84
88
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
85
89
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
@@ -192,7 +196,7 @@ export async function writeConfig(config: Config): Promise<void> {
192
196
  }
193
197
 
194
198
 
195
- type ToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput";
199
+ type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput";
196
200
 
197
201
  async function toggleFlag(key: ToggleKey): Promise<boolean> {
198
202
  const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
@@ -200,6 +204,7 @@ async function toggleFlag(key: ToggleKey): Promise<boolean> {
200
204
  }
201
205
  export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
202
206
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
207
+ export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
203
208
  export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
204
209
  let next: AutoReadAllMode = "off";
205
210
  await updateConfig((c) => {
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@ export const MAX_GREP_LINE_BYTES = 500;
9
9
  export const MAX_DIFF_INPUT_BYTES = 1024 * 1024;
10
10
 
11
11
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
12
- export const HASH_STORE_VERSION = 8;
12
+ export const HASH_STORE_VERSION = 9;
13
13
  export const NEW_CONTENT_NOT_ARRAY_MSG =
14
14
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
15
15
 
@@ -19,6 +19,9 @@ export const NUL_CONTENT_MSG =
19
19
  export const ANCHOR_POOL_EXHAUSTED_PREFIX =
20
20
  "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
21
21
 
22
+ export const ANCHOR_RECLAIM_WARNING_CODE = "[W_ANCHOR_RECLAIMED]";
23
+ export const MAX_RECLAIMED_PATHS_REPORTED = 10;
24
+
22
25
  export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
23
26
  export const AUTO_READ_ALL_MAX_FILES = 500;
24
27
  export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;