pi-hashline-edit-pro 4.4.1 → 4.4.2

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/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
- import { initHasher, lineChecksum } from "./src/hashline";
3
+ import { initHasher } from "./src/hashline";
4
4
  import { regReplace } from "./src/replace";
5
5
  import { regInsert } from "./src/insert";
6
6
  import { regGrep } from "./src/grep";
@@ -23,7 +23,7 @@ import {
23
23
  adjustDiffContextLines,
24
24
  setAutoReadAllIgnoreFromText,
25
25
  } from "./src/config";
26
- import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
26
+ import { loadHashStore, pruneMissing } from "./src/hash-store";
27
27
  import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
28
28
  import { serveRows } from "./src/served";
29
29
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
@@ -216,7 +216,6 @@ export default function (pi: ExtensionAPI): void {
216
216
  DEFAULT_MAX_LINES,
217
217
  );
218
218
  const fileLines = splitLines(normalized);
219
- persistSnapshot(await loadHashStore(), absolutePath, normalized, fileHashes, fileLines.map(lineChecksum));
220
219
  serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
221
220
  return {
222
221
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.4.1",
3
+ "version": "4.4.2",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -671,7 +671,7 @@ export function alignOwnershipWithSpans(
671
671
  prevAnchors: string[],
672
672
  prevChecksums: string[],
673
673
  newChecksums: string[],
674
- spans: { start: number; end: number; replacementCount: number }[],
674
+ spans: { start: number; end: number; replacementCount: number; carry?: number }[],
675
675
  options?: { shadow?: boolean },
676
676
  ): Aligned {
677
677
  const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
@@ -699,15 +699,24 @@ export function alignOwnershipWithSpans(
699
699
  }
700
700
  }
701
701
  const replacement: (string | undefined)[] = new Array(span.replacementCount);
702
- for (let k = 0; k < span.replacementCount; k++) {
703
- const positional = k < spanLength ? prevAnchors[prevStart + k] : undefined;
704
- if (
705
- positional !== undefined &&
706
- prevChecksums[prevStart + k] === newChecksums[start + k] &&
707
- (!state.owned.has(positional) || state.owned.get(positional)!.path === path)
708
- ) {
709
- replacement[k] = positional;
710
- reused.add(positional);
702
+ const carryIndex =
703
+ span.carry !== undefined && span.carry >= 0 && span.carry < span.replacementCount ? span.carry : undefined;
704
+ const carried = carryIndex === undefined ? undefined : prevAnchors[prevStart];
705
+ if (carryIndex !== undefined && carried !== undefined && prevChecksums[prevStart] === newChecksums[start + carryIndex]) {
706
+ replacement[carryIndex] = carried;
707
+ reused.add(carried);
708
+ } else {
709
+ for (let k = 0; k < span.replacementCount; k++) {
710
+ if (k === carryIndex) continue;
711
+ const positional = k < spanLength ? prevAnchors[prevStart + k] : undefined;
712
+ if (
713
+ positional !== undefined &&
714
+ prevChecksums[prevStart + k] === newChecksums[start + k] &&
715
+ (!state.owned.has(positional) || state.owned.get(positional)!.path === path)
716
+ ) {
717
+ replacement[k] = positional;
718
+ reused.add(positional);
719
+ }
711
720
  }
712
721
  }
713
722
  for (let k = 0; k < span.replacementCount; k++) {
@@ -819,7 +828,7 @@ export async function allocateFileAnchors(
819
828
  options?: {
820
829
  persist?: boolean;
821
830
  shadow?: boolean;
822
- previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] };
831
+ previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number; carry?: number }[] };
823
832
  },
824
833
  ): Promise<string[]> {
825
834
  ensureRegistry();
@@ -24,7 +24,6 @@ const EXEC_MAX_BYTES = 64 * 1024 * 1024;
24
24
  const SCAN_CONCURRENCY = 32;
25
25
  const SCAN_LIMIT_MULTIPLIER = 4;
26
26
  const MAX_REPORTED_OMISSIONS = 50;
27
- export const AUTO_READ_ALL_CHUNK_BYTES = 48 * 1024;
28
27
 
29
28
  const HEADER =
30
29
  "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for files in [files complete: ...].\nFiles listed as omitted or not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
@@ -87,24 +86,17 @@ function isExcludedBySegment(path: string): boolean {
87
86
  }
88
87
  return false;
89
88
  }
89
+ const EXCLUDED_PATTERN_SUFFIXES = [
90
+ ".min.js", ".min.css", ".min.mjs", "-min.js", "-min.css", ".umd.js", ".map", ".lock",
91
+ "_pb2.py", ".pb.go", ".g.dart", ".freezed.dart", ".designer.cs", ".g.cs", ".snap",
92
+ ];
93
+ const EXCLUDED_PATTERN_INFIXES = [".bundle.", ".chunk.", ".generated.", ".gen."];
94
+
90
95
  function isExcludedByPattern(baseLower: string): boolean {
91
- if (baseLower.endsWith(".min.js") || baseLower.endsWith(".min.css") || baseLower.endsWith(".min.mjs")) return true;
92
- if (baseLower.endsWith("-min.js") || baseLower.endsWith("-min.css")) return true;
93
- if (baseLower.includes(".bundle.") || baseLower.includes(".chunk.")) return true;
94
- if (baseLower.endsWith(".umd.js")) return true;
95
- if (baseLower.endsWith(".map")) return true;
96
- if (baseLower.endsWith(".lock")) return true;
97
- if (baseLower.includes(".generated.") || baseLower.includes(".gen.")) return true;
98
- if (baseLower.endsWith("_pb2.py")) return true;
99
- if (baseLower.endsWith(".pb.go")) return true;
100
- if (baseLower.endsWith(".g.dart")) return true;
101
- if (baseLower.endsWith(".freezed.dart")) return true;
102
- if (baseLower.endsWith(".designer.cs")) return true;
103
- if (baseLower.endsWith(".g.cs")) return true;
104
- if (baseLower.endsWith(".snap")) return true;
105
- if (baseLower.startsWith("coreui-icons.")) return true;
106
- if (baseLower === "coreui.css") return true;
107
- return false;
96
+ return EXCLUDED_PATTERN_SUFFIXES.some((suffix) => baseLower.endsWith(suffix))
97
+ || EXCLUDED_PATTERN_INFIXES.some((infix) => baseLower.includes(infix))
98
+ || baseLower.startsWith("coreui-icons.")
99
+ || baseLower === "coreui.css";
108
100
  }
109
101
  export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
110
102
  const seen = new Set<string>();
@@ -203,7 +195,6 @@ export interface AutoReadAllInjection {
203
195
  files: number;
204
196
  bytes: number;
205
197
  omitted: string[];
206
- chunks: string[];
207
198
  completeFiles: number;
208
199
  }
209
200
 
@@ -371,24 +362,6 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
371
362
  return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
372
363
  }
373
364
 
374
- export function chunkAutoReadAllSections(sections: string[], maxBytes: number = AUTO_READ_ALL_CHUNK_BYTES): string[] {
375
- const chunks: string[] = [];
376
- let current: string[] = [];
377
- let currentBytes = 0;
378
- for (const section of sections) {
379
- const sectionBytes = Buffer.byteLength(section, "utf-8") + 2;
380
- if (current.length > 0 && currentBytes + sectionBytes > maxBytes) {
381
- chunks.push(current.join("\n\n"));
382
- current = [];
383
- currentBytes = 0;
384
- }
385
- current.push(section);
386
- currentBytes += sectionBytes;
387
- }
388
- if (current.length > 0) chunks.push(current.join("\n\n"));
389
- return chunks;
390
- }
391
-
392
365
  async function candidateFileBytes(cwd: string, file: string): Promise<number | undefined> {
393
366
  try {
394
367
  const stats = await lstat(resolve(cwd, file));
@@ -462,12 +435,11 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
462
435
  }
463
436
  if (sections.length === 0) return undefined;
464
437
  const sectionTexts = sections.map((section) => section.text);
465
- const chunks = chunkAutoReadAllSections(sectionTexts);
466
438
  const coverage = `[coverage: ${completeFiles} complete]`;
467
439
  const completeNames = sections.map((section) => section.file);
468
440
  const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
469
441
  const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
470
- return { text, files: sections.length, bytes, omitted, chunks, completeFiles };
442
+ return { text, files: sections.length, bytes, omitted, completeFiles };
471
443
  }
472
444
 
473
445
  export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
package/src/batch.ts CHANGED
@@ -63,6 +63,7 @@ export interface BatchPiece {
63
63
  warnings: string[];
64
64
  noop: boolean;
65
65
  foldedLines: number;
66
+ carryIndex?: number;
66
67
  }
67
68
 
68
69
  export interface BatchMemberInput {
@@ -495,10 +496,18 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
495
496
  const baseLines = base.baseLines;
496
497
  const originalSlice = baseLines.slice(start - 1, end);
497
498
  const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
499
+ const foldedLines = input.foldedLines ?? 0;
500
+ const carryIndex =
501
+ input.kind === "insert" && foldedLines > 0
502
+ ? input.direction === "after"
503
+ ? 0
504
+ : newLines.length - 1
505
+ : undefined;
498
506
  const piece: BatchPiece = {
499
507
  order: input.member.order,
500
508
  kind: input.kind,
501
509
  ...(input.direction !== undefined ? { direction: input.direction } : {}),
510
+ ...(carryIndex !== undefined ? { carryIndex } : {}),
502
511
  start,
503
512
  end,
504
513
  fromHash: input.hedit.hash_bounds[0].hash,
@@ -506,7 +515,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
506
515
  newLines: [...newLines],
507
516
  warnings: [...input.extraWarnings, ...planned.warnings],
508
517
  noop,
509
- foldedLines: input.foldedLines ?? 0,
518
+ foldedLines,
510
519
  };
511
520
  runtime.pieces.push(piece);
512
521
  if (input.kind === "replace") runtime.replaceCount += 1;
@@ -571,6 +580,15 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
571
580
  return merged;
572
581
  }
573
582
 
583
+ function pieceMappingSpans(pieces: BatchPiece[]): { start: number; end: number; replacementCount: number; carry?: number }[] {
584
+ return pieces.map((piece) => ({
585
+ start: piece.start - 1,
586
+ end: piece.end - 1,
587
+ replacementCount: piece.newLines.length,
588
+ ...(piece.carryIndex !== undefined ? { carry: piece.carryIndex } : {}),
589
+ }));
590
+ }
591
+
574
592
  async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
575
593
  const runtime = batches.get(member.batchKey);
576
594
  if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
@@ -637,7 +655,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
637
655
  discardBatchState(runtime);
638
656
  throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
639
657
  }
640
- const preflightSpans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
658
+ const preflightSpans = pieceMappingSpans(effectivePieces);
641
659
  try {
642
660
  await lineHashes(composed, runtime.target, {
643
661
  content: base.content,
@@ -670,7 +688,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
670
688
  throw error;
671
689
  }
672
690
  const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
673
- const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
691
+ const spans = pieceMappingSpans(effectivePieces);
674
692
  let resultHashes: string[];
675
693
  try {
676
694
  resultHashes = await lineHashes(composed, runtime.target, {
package/src/commit.ts CHANGED
@@ -12,6 +12,7 @@ import { spanForEdit } from "./replace";
12
12
  import { restoreEndings, stripBOM, toLF } from "./normalize";
13
13
  export interface CommitMeta {
14
14
  editAnchors?: [string, string];
15
+ anchorCarry?: number;
15
16
  path: string;
16
17
  absolutePath: string;
17
18
  mutationTargetPath: string;
@@ -107,6 +108,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
107
108
  };
108
109
 
109
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;
110
112
  let resultHashes: string[];
111
113
  try {
112
114
  resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
package/src/hash-store.ts CHANGED
@@ -529,6 +529,7 @@ export function getAllocatedState(store: HashStore, path: string, deleteCorrupt
529
529
  checksums = undefined;
530
530
  }
531
531
  }
532
+ if (checksums !== undefined && checksums.length !== parsedAnchors.length) return undefined;
532
533
  return { anchors: parsedAnchors, checksums, contentChecksum: row.checksum };
533
534
  }
534
535
 
@@ -550,8 +551,13 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
550
551
  if (!row) return undefined;
551
552
  const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
552
553
  if (!parsed) return undefined;
554
+ const content = row.content as string;
555
+ if (splitLines(content).length !== parsed.length) {
556
+ store.stmts.undoDelete(path);
557
+ return undefined;
558
+ }
553
559
  return {
554
- content: row.content as string,
560
+ content,
555
561
  bom: row.bom as string,
556
562
  ending: row.ending as string,
557
563
  hashes: parsed,
@@ -101,7 +101,7 @@ export function _lineHashesPure(content: string): string[] {
101
101
  export async function lineHashes(
102
102
  content: string,
103
103
  path?: string,
104
- previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] },
104
+ previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number; carry?: number }[] },
105
105
  store?: HashStore,
106
106
  persist?: boolean,
107
107
  shadow?: boolean,
@@ -38,7 +38,7 @@ function diagRef(ref: string): string {
38
38
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-character anchor (letters only, e.g. "Hasu").`;
39
39
  }
40
40
 
41
- function parseRef(ref: string): Anchor {
41
+ export function parseHashRef(ref: string): Anchor {
42
42
  const trimmed = ref.trim();
43
43
 
44
44
  if (
@@ -51,8 +51,6 @@ function parseRef(ref: string): Anchor {
51
51
  throw new Error(diagRef(ref));
52
52
  }
53
53
 
54
- export const parseHashRef = parseRef;
55
-
56
54
  const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
57
55
 
58
56
  function unwrapJsonEnvelope(line: string): string {
package/src/insert.ts CHANGED
@@ -241,6 +241,10 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
241
241
  path: pipe.path,
242
242
  absolutePath,
243
243
  mutationTargetPath,
244
+ editAnchors: [editParams.remove_from, editParams.remove_to],
245
+ ...(anchorLine === undefined
246
+ ? {}
247
+ : { anchorCarry: req.direction === "after" ? 0 : editParams.replacement_lines.length - 1 }),
244
248
  signal,
245
249
  verb: "inserted",
246
250
  noopNoun: "Insertion",
@@ -23,6 +23,7 @@ export interface DiffSpan {
23
23
  start: number;
24
24
  end: number;
25
25
  replacementCount: number;
26
+ carry?: number;
26
27
  }
27
28
 
28
29
  interface PlacedSpan {
@@ -37,10 +38,7 @@ function fmtDiffLine(
37
38
  line: string,
38
39
  hash: string | undefined,
39
40
  ): string {
40
- if (hash === undefined) {
41
- return `${prefix}${" ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
42
- }
43
- return `${prefix}${hash}${HASH_SEP}${line}`;
41
+ return `${prefix}${hash ?? " ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
44
42
  }
45
43
 
46
44
  function isBlankLine(line: string): boolean {
package/src/utils.ts CHANGED
@@ -99,30 +99,6 @@ export function errCode(error: unknown): string | undefined {
99
99
  return undefined;
100
100
  }
101
101
 
102
- export function lastNonEmptyIndex(lines: string[]): number {
103
- for (let i = lines.length - 1; i >= 0; i--) {
104
- if (lines[i]!.length > 0) return i;
105
- }
106
- return -1;
107
- }
108
-
109
- export function firstNonEmptyIndex(lines: string[]): number {
110
- for (let i = 0; i < lines.length; i++) {
111
- if (lines[i]!.length > 0) return i;
112
- }
113
- return -1;
114
- }
115
-
116
- export function lastNonEmpty(lines: string[]): string | undefined {
117
- const idx = lastNonEmptyIndex(lines);
118
- return idx >= 0 ? lines[idx] : undefined;
119
- }
120
-
121
- export function firstNonEmpty(lines: string[]): string | undefined {
122
- const idx = firstNonEmptyIndex(lines);
123
- return idx >= 0 ? lines[idx] : undefined;
124
- }
125
-
126
102
  export function truncateToBytes(s: string, maxBytes: number): string {
127
103
  if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
128
104
  let out = "";