pi-hashline-edit-pro 2.8.1 → 2.8.3

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.
@@ -2,6 +2,7 @@ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, cl
2
2
  import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
+ import { contentChecksum } from "./hasher";
5
6
 
6
7
  export type RAnchor = {
7
8
  line: number;
@@ -90,14 +91,13 @@ export function fmtMismatchWithHashes(
90
91
  fileLines: string[],
91
92
  fileHashes: string[],
92
93
  filePath?: string,
93
- ): { text: string; hashes: string[] } {
94
+ ): { text: string; hashes: string[]; servedMap: Map<string, string> } {
94
95
  assertAligned(fileLines, fileHashes, "fmtMismatch");
95
-
96
96
  const out: string[] = [];
97
97
  const hashes: string[] = [];
98
+ const servedMap = new Map<string, string>();
98
99
  const notFound = mismatches.filter((m) => m.kind === "not_found");
99
100
  const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
100
-
101
101
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
102
102
  if (notFound.length > 0) {
103
103
  out.push(
@@ -110,8 +110,11 @@ export function fmtMismatchWithHashes(
110
110
  const to = Math.min(fileLines.length, ctx.line + 1);
111
111
  const rows: string[] = [];
112
112
  for (let ln = from; ln <= to; ln++) {
113
- hashes.push(fileHashes[ln - 1]!);
114
- rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
113
+ const h = fileHashes[ln - 1]!;
114
+ const c = fileLines[ln - 1] ?? "";
115
+ hashes.push(h);
116
+ servedMap.set(h, contentChecksum(c));
117
+ rows.push(` ${ln}: ${h}│${clipLine(c)}`);
115
118
  }
116
119
  out.push("");
117
120
  out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
@@ -128,7 +131,12 @@ export function fmtMismatchWithHashes(
128
131
  (m.candidates?.length ?? 0) > sample.length
129
132
  ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
130
133
  : "";
131
- for (const line of sample) hashes.push(fileHashes[line - 1]!);
134
+ for (const line of sample) {
135
+ const h = fileHashes[line - 1]!;
136
+ const c = fileLines[line - 1] ?? "";
137
+ hashes.push(h);
138
+ servedMap.set(h, contentChecksum(c));
139
+ }
132
140
  const lines = sample
133
141
  .map((line) => {
134
142
  const content = clipLine(fileLines[line - 1] ?? "");
@@ -140,8 +148,7 @@ export function fmtMismatchWithHashes(
140
148
  );
141
149
  }
142
150
  }
143
-
144
- return { text: out.join("\n"), hashes };
151
+ return { text: out.join("\n"), hashes, servedMap };
145
152
  }
146
153
 
147
154
 
@@ -482,45 +489,49 @@ export function valEdit(
482
489
  }
483
490
 
484
491
  export function resolveAnchorLine(
485
- ref: Anchor,
486
- fileLines: string[],
487
- fileHashes: string[],
488
- filePath?: string,
492
+ ref: Anchor,
493
+ fileLines: string[],
494
+ fileHashes: string[],
495
+ filePath?: string,
489
496
  ): number {
490
- const { resolved, mismatches } = valEdit(
491
- { hash_bounds: [ref, ref], content_lines: [] },
492
- fileLines,
493
- fileHashes,
494
- [],
495
- undefined,
496
- );
497
- if (mismatches.length > 0 || !resolved) {
498
- const feedback = fmtMismatchWithHashes(
499
- mismatches,
500
- fileLines,
501
- fileHashes,
502
- filePath,
503
- );
504
- throw new AnchorMismatchError(feedback.text, feedback.hashes);
505
- }
506
- return resolved.hash_bounds[0].line;
497
+ const { resolved, mismatches } = valEdit(
498
+ { hash_bounds: [ref, ref], content_lines: [] },
499
+ fileLines,
500
+ fileHashes,
501
+ [],
502
+ undefined,
503
+ );
504
+ if (mismatches.length > 0 || !resolved) {
505
+ const feedback = fmtMismatchWithHashes(
506
+ mismatches,
507
+ fileLines,
508
+ fileHashes,
509
+ filePath,
510
+ );
511
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
512
+ }
513
+ return resolved.hash_bounds[0].line;
507
514
  }
508
515
 
509
516
  export class RangeStaleError extends Error {
510
517
  readonly rangeHashes: string[];
511
- constructor(message: string, rangeHashes: string[]) {
518
+ readonly rangeServedMap: Map<string, string>;
519
+ constructor(message: string, rangeHashes: string[], rangeServedMap: Map<string, string>) {
512
520
  super(message);
513
521
  this.name = "RangeStaleError";
514
522
  this.rangeHashes = rangeHashes;
523
+ this.rangeServedMap = rangeServedMap;
515
524
  }
516
525
  }
517
526
 
518
527
  export class AnchorMismatchError extends Error {
519
528
  readonly feedbackHashes: string[];
520
- constructor(message: string, feedbackHashes: string[]) {
529
+ readonly feedbackMap: Map<string, string>;
530
+ constructor(message: string, feedbackHashes: string[], feedbackMap: Map<string, string>) {
521
531
  super(message);
522
532
  this.name = "AnchorMismatchError";
523
533
  this.feedbackHashes = feedbackHashes;
534
+ this.feedbackMap = feedbackMap;
524
535
  }
525
536
  }
526
537
 
@@ -528,7 +539,7 @@ export function assertRangeServed(
528
539
  resolved: RHEdit,
529
540
  fileLines: string[],
530
541
  fileHashes: string[],
531
- served: ReadonlySet<string>,
542
+ served: ReadonlyMap<string, string> | undefined,
532
543
  filePath?: string,
533
544
  ): void {
534
545
  assertAligned(fileLines, fileHashes, "assertRangeServed");
@@ -536,18 +547,23 @@ export function assertRangeServed(
536
547
  const endLine = resolved.hash_bounds[1].line;
537
548
  const mismatchLines: number[] = [];
538
549
  for (let line = startLine; line <= endLine; line++) {
539
- if (!served.has(fileHashes[line - 1]!)) mismatchLines.push(line);
550
+ const hash = fileHashes[line - 1]!;
551
+ const content = fileLines[line - 1]!;
552
+ const servedContent = served?.get(hash);
553
+ if (servedContent === undefined || servedContent !== contentChecksum(content)) mismatchLines.push(line);
540
554
  }
541
555
  if (mismatchLines.length === 0) return;
542
-
543
556
  const rangeLength = endLine - startLine + 1;
544
557
  const shownLength = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
545
558
  const rows: string[] = [];
546
559
  const shownHashes: string[] = [];
560
+ const shownMap = new Map<string, string>();
547
561
  for (let line = startLine; line < startLine + shownLength; line++) {
548
562
  const hash = fileHashes[line - 1]!;
563
+ const content = fileLines[line - 1]!;
549
564
  shownHashes.push(hash);
550
- rows.push(fmtRow(hash, clipLine(fileLines[line - 1])));
565
+ shownMap.set(hash, contentChecksum(content));
566
+ rows.push(fmtRow(hash, clipLine(content)));
551
567
  }
552
568
  const location = filePath ? ` in ${filePath}` : "";
553
569
  const first = mismatchLines[0]!;
@@ -561,7 +577,7 @@ export function assertRangeServed(
561
577
  : "";
562
578
  const message =
563
579
  `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
564
- throw new RangeStaleError(message, shownHashes);
580
+ throw new RangeStaleError(message, shownHashes, shownMap);
565
581
  }
566
582
 
567
583
  export { warnUnicodeEsc };
package/src/read.ts CHANGED
@@ -13,8 +13,8 @@ import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
13
13
  import { readNormFile, safeSnapId } from "./file-reader";
14
14
  import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
15
15
  import { toCwd } from "./paths";
16
- import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
17
- import { recordServedSafe } from "./served";
16
+ import { abortIf, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
17
+ import { recordServedSafe, buildServedMap } from "./served";
18
18
  import { loadP, loadGuide } from "./prompts";
19
19
  import { valAccess } from "./validation";
20
20
  import { Text } from "@earendil-works/pi-tui";
@@ -236,7 +236,9 @@ export function regRead(pi: ExtensionAPI): void {
236
236
  fileHashes,
237
237
  resolvedPath,
238
238
  );
239
- await recordServedSafe(resolvedPath, preview.servedHashes, "read", new Set(fileHashes));
239
+ const fileLines = splitLines(normalized);
240
+ const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
241
+ await recordServedSafe(resolvedPath, servedMap, "read", new Set(fileHashes));
240
242
  const snapshotId = await safeSnapId(absolutePath, "read");
241
243
  const previewText =
242
244
  hadUtf8DecodeErrors
@@ -4,11 +4,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
  import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
7
- import { recordServedDiff } from "./served";
7
+ import { recordServed, buildServedMap, servedHashesFromDiff } from "./served";
8
8
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
9
9
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
10
10
  import { genDiff, genPatch } from "./replace-diff";
11
- import { cntDiff, errCode, makePrepareArguments } from "./utils";
11
+ import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
14
14
  import { renderEditResult } from "./replace-render";
@@ -172,7 +172,10 @@ export function regUndo(pi: ExtensionAPI): void {
172
172
  try {
173
173
  const store = await loadHashStore();
174
174
  persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
175
- recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
175
+ const diffHashes = servedHashesFromDiff(undoDiff);
176
+ const undoLines = splitLines(undo.content);
177
+ const servedMap = buildServedMap(undo.hashes, undoLines, diffHashes);
178
+ recordServed(store, mutationTargetPath, servedMap, new Set(undo.hashes));
176
179
  } catch (error) {
177
180
  console.error("Failed to restore hash store snapshot after undo:", error);
178
181
  }
package/src/replace.ts CHANGED
@@ -119,8 +119,8 @@ function hashSpan(hashes: string[], from: string, to: string): [number, number]
119
119
  }
120
120
  async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
121
121
  if (noPersist === true) return;
122
- if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(scopeHashes));
123
- else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(scopeHashes));
122
+ if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeServedMap, "range-stale feedback", new Set(scopeHashes));
123
+ else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackMap, "anchor-mismatch feedback", new Set(scopeHashes));
124
124
  }
125
125
 
126
126
  function collectRemovedHashes(
package/src/served.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { loadHashStore, parseStoredHashes, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
1
+ import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
+ import { contentChecksum } from "./hashline/hasher";
3
4
 
4
5
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
5
6
 
@@ -12,60 +13,88 @@ export function servedHashesFromDiff(diff: string): string[] {
12
13
  return hashes;
13
14
  }
14
15
 
15
- export function getServed(store: HashStore, path: string): Set<string> | undefined {
16
+ export function servedMapFromDiff(diff: string): Map<string, string> {
17
+ const map = new Map<string, string>();
18
+ for (const line of diff.split("\n")) {
19
+ const match = SERVED_DIFF_ROW_RE.exec(line);
20
+ if (!match) continue;
21
+ const hash = match[1]!;
22
+ const content = line.slice(match[0].length);
23
+ map.set(hash, contentChecksum(content));
24
+ }
25
+ return map;
26
+ }
27
+
28
+ export function buildServedMap(fileHashes: string[], fileLines: string[], wantedHashes: string[]): Map<string, string> {
29
+ const index = new Map<string, number>();
30
+ for (let i = 0; i < fileHashes.length; i++) index.set(fileHashes[i]!, i);
31
+ const map = new Map<string, string>();
32
+ for (const h of wantedHashes) {
33
+ const idx = index.get(h);
34
+ if (idx !== undefined) map.set(h, contentChecksum(fileLines[idx]!));
35
+ }
36
+ return map;
37
+ }
38
+
39
+ export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
16
40
  const row = store.stmts.servedGet(path);
17
- const parsed = parseStoredHashes(row, () => store.stmts.servedDelete(path));
41
+ const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
18
42
  if (!parsed) return undefined;
19
- return new Set(parsed);
43
+ return parsed;
20
44
  }
21
45
 
22
46
  function computeUpdate(
23
47
  store: HashStore,
24
48
  path: string,
25
- hashes: string[],
49
+ entries: Map<string, string>,
26
50
  scope?: ReadonlySet<string>,
27
- ): Set<string> | undefined {
51
+ ): Map<string, string> | undefined {
28
52
  const existing = getServed(store, path);
29
- if (!existing && hashes.length === 0) return undefined;
30
- const set = existing ?? new Set<string>();
53
+ if (!existing && entries.size === 0 && !scope) return undefined;
54
+ const map = existing ? new Map(existing) : new Map<string, string>();
31
55
  let changed = false;
32
56
  if (scope) {
33
- for (const hash of set) {
57
+ for (const hash of [...map.keys()]) {
34
58
  if (!scope.has(hash)) {
35
- set.delete(hash);
59
+ map.delete(hash);
36
60
  changed = true;
37
61
  }
38
62
  }
39
63
  }
40
- for (const hash of hashes) {
41
- if (!set.has(hash)) {
42
- set.add(hash);
64
+ for (const [hash, content] of entries) {
65
+ const prev = map.get(hash);
66
+ if (prev !== content) {
67
+ map.set(hash, content);
43
68
  changed = true;
44
69
  }
45
70
  }
46
- if (!changed) return undefined;
47
- return set;
71
+ if (!changed && existing) return undefined;
72
+ if (map.size === 0 && (!existing || existing.size === 0)) return undefined;
73
+ if (!changed) return existing;
74
+ return map;
48
75
  }
49
76
 
50
77
  export function recordServed(
51
78
  store: HashStore,
52
79
  path: string,
53
- hashes: string[],
80
+ entries: Map<string, string>,
54
81
  scope?: ReadonlySet<string>,
55
82
  ): void {
56
83
  try {
57
84
  withStore(() => {
58
- const set = computeUpdate(store, path, hashes, scope);
59
- if (!set) return;
60
- store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
85
+ const map = computeUpdate(store, path, entries, scope);
86
+ if (!map) return;
87
+ const obj = Object.fromEntries(map);
88
+ store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
61
89
  });
62
90
  return;
63
91
  } catch (error) {
64
92
  if (!(error instanceof Error && error.message === STORE_NOT_OPEN_MESSAGE)) throw error;
65
93
  }
66
- const set = computeUpdate(store, path, hashes, scope);
67
- if (!set) return;
68
- store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
94
+ const map = computeUpdate(store, path, entries, scope);
95
+ if (!map) return;
96
+ const obj = Object.fromEntries(map);
97
+ store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
69
98
  }
70
99
 
71
100
  export function recordServedDiff(
@@ -74,7 +103,7 @@ export function recordServedDiff(
74
103
  diff: string,
75
104
  scope?: ReadonlySet<string>,
76
105
  ): void {
77
- recordServed(store, path, servedHashesFromDiff(diff), scope);
106
+ recordServed(store, path, servedMapFromDiff(diff), scope);
78
107
  }
79
108
 
80
109
  export function clearServed(store: HashStore, path: string): void {
@@ -83,14 +112,14 @@ export function clearServed(store: HashStore, path: string): void {
83
112
 
84
113
  export async function recordServedSafe(
85
114
  path: string,
86
- hashes: string[],
115
+ entries: Map<string, string>,
87
116
  context: string,
88
117
  scope?: ReadonlySet<string>,
89
118
  ): Promise<void> {
90
- if (hashes.length === 0 && !scope) return;
119
+ if (entries.size === 0 && !scope) return;
91
120
  try {
92
121
  const store = await loadHashStore();
93
- recordServed(store, path, hashes, scope);
122
+ recordServed(store, path, entries, scope);
94
123
  } catch (error) {
95
124
  console.error(`Failed to record served state (${context}):`, error);
96
125
  }
@@ -103,5 +132,5 @@ export async function recordServedDiffSafe(
103
132
  scope?: ReadonlySet<string>,
104
133
  ): Promise<void> {
105
134
  if (!diff) return;
106
- await recordServedSafe(path, servedHashesFromDiff(diff), context, scope);
135
+ await recordServedSafe(path, servedMapFromDiff(diff), context, scope);
107
136
  }
package/src/write-hook.ts CHANGED
@@ -8,19 +8,19 @@ import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
8
8
 
9
9
  const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
10
10
 
11
- function searchEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
11
+ function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
12
12
  for (let i = 0; i < lines.length; i++) {
13
13
  const match = HASH_ECHO_RE.exec(lines[i]!);
14
- if (match && served.has(match[1]!)) return { line: i + 1, hash: match[1]! };
14
+ if (match && served.has(match[1]! as never)) return { line: i + 1, hash: match[1]! };
15
15
  }
16
16
  return undefined;
17
17
  }
18
18
 
19
- export function findServedHashEcho(content: string, served: ReadonlySet<string>): { line: number; hash: string } | undefined {
19
+ export function findServedHashEcho(content: string, served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
20
20
  return searchEcho(splitLines(content), served);
21
21
  }
22
22
 
23
- export function findEditHashEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
23
+ export function findEditHashEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
24
24
  return searchEcho(lines, served);
25
25
  }
26
26