pi-hashline-edit-pro 2.7.0 → 2.7.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/src/grep.ts CHANGED
@@ -1,20 +1,21 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
4
  import { readdir, stat } from "fs/promises";
4
5
  import { dirname, join, relative } from "path";
5
6
  import { loadFileKindAndText } from "./file-kind";
6
7
  import { readNormFile } from "./file-reader";
7
- import { MAX_HASH_LINES, HASH_SEP } from "./hashline";
8
+ import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
9
+ import { MAX_GREP_LINE_BYTES } from "./constants";
8
10
  import { toCwd } from "./paths";
9
11
  import { loadP, loadGuide } from "./prompts";
10
12
  import { normReq } from "./replace-normalize";
11
13
  import { recordServedSafe } from "./served";
12
- import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
14
+ import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
13
15
 
14
16
  const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
15
17
  const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
16
18
  const MAX_SCAN_FILES = 4000;
17
- const MAX_SHOWN_ROWS = 2000;
18
19
 
19
20
  export interface GrepReq {
20
21
  pattern: string;
@@ -92,6 +93,42 @@ interface FileHit {
92
93
  hashes: string[];
93
94
  matchCount: number;
94
95
  totalMatchCount: number;
96
+ fragmented: boolean[];
97
+ }
98
+
99
+ const GREP_ROW_OVERHEAD_BYTES = HASH_LEN + Buffer.byteLength(HASH_SEP, "utf-8");
100
+ const GREP_ROW_CONTENT_BYTES = MAX_GREP_LINE_BYTES - GREP_ROW_OVERHEAD_BYTES;
101
+
102
+ function snapCharBoundaries(line: string, start: number, end: number): [number, number] {
103
+ let s = start;
104
+ let e = end;
105
+ if (s > 0 && s < line.length) {
106
+ const c = line.charCodeAt(s);
107
+ if (c >= 0xdc00 && c <= 0xdfff && line.charCodeAt(s - 1) >= 0xd800 && line.charCodeAt(s - 1) <= 0xdbff) s -= 1;
108
+ }
109
+ if (e > 0 && e < line.length) {
110
+ const c = line.charCodeAt(e - 1);
111
+ if (c >= 0xd800 && c <= 0xdbff && line.charCodeAt(e) >= 0xdc00 && line.charCodeAt(e) <= 0xdfff) e += 1;
112
+ }
113
+ return [s, e];
114
+ }
115
+
116
+ function grepMatchFragment(line: string, regex: RegExp): string {
117
+ const m = regex.exec(line);
118
+ const matchStart = m?.index ?? 0;
119
+ const matchLen = m?.[0].length ?? 0;
120
+ const budget = GREP_ROW_CONTENT_BYTES - 6;
121
+ const half = Math.floor((budget - Math.min(matchLen, budget)) / 2);
122
+ const [start, end] = snapCharBoundaries(line, Math.max(0, matchStart - half), Math.min(line.length, matchStart + matchLen + half));
123
+ const content = truncateToBytes(line.slice(start, end), budget);
124
+ const lead = start > 0 ? "..." : "";
125
+ const tail = end < line.length ? "..." : "";
126
+ return truncateToBytes(`${lead}${content}${tail}`, GREP_ROW_CONTENT_BYTES);
127
+ }
128
+
129
+ function grepHeadFragment(line: string): string {
130
+ const head = truncateToBytes(line, GREP_ROW_CONTENT_BYTES - 3);
131
+ return head.length < line.length ? `${head}...` : head;
95
132
  }
96
133
 
97
134
  interface ScanState {
@@ -155,7 +192,7 @@ async function searchFile(
155
192
  if (file.kind !== "text") return undefined;
156
193
  let norm;
157
194
  try {
158
- norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file });
195
+ norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file, noPersist: true });
159
196
  } catch (error) {
160
197
  if (isSkipableLoadError(error)) return undefined;
161
198
  throw error;
@@ -172,11 +209,24 @@ async function searchFile(
172
209
  for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
173
210
  }
174
211
  const sorted = [...shown].sort((a, b) => a - b);
212
+ const matchSet = new Set(matchLines);
175
213
  const rows: string[] = [];
176
214
  const hashes: string[] = [];
215
+ const fragmented: boolean[] = [];
177
216
  for (const idx of sorted) {
178
- rows.push(`${norm.fileHashes[idx]}${HASH_SEP}${lines[idx]}`);
179
- hashes.push(norm.fileHashes[idx]!);
217
+ const hash = norm.fileHashes[idx]!;
218
+ const line = lines[idx]!;
219
+ const row = fmtRow(hash, line);
220
+ if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
221
+ const content = matchSet.has(idx) ? grepMatchFragment(line, regex) : grepHeadFragment(line);
222
+ rows.push(fmtRow(hash, content));
223
+ hashes.push(hash);
224
+ fragmented.push(true);
225
+ } else {
226
+ rows.push(row);
227
+ hashes.push(hash);
228
+ fragmented.push(false);
229
+ }
180
230
  }
181
231
  return {
182
232
  path: norm.absolutePath,
@@ -186,6 +236,7 @@ async function searchFile(
186
236
  hashes,
187
237
  matchCount: keptMatches.length,
188
238
  totalMatchCount: matchLines.length,
239
+ fragmented,
189
240
  };
190
241
  }
191
242
 
@@ -274,8 +325,29 @@ export function regGrep(pi: ExtensionAPI): void {
274
325
  let limitTruncated = false;
275
326
  let rowTruncated = false;
276
327
  let rowCount = 0;
277
- for (const absPath of files) {
328
+ let byteCount = 0;
329
+ let totalRows = 0;
330
+ let totalBytes = 0;
331
+ let truncatedBy: "lines" | "bytes" | null = null;
332
+ let linesReplaced = 0;
333
+ let countOnly = false;
334
+ for (let f = 0; f < files.length; f++) {
278
335
  abortIf(signal);
336
+ const absPath = files[f]!;
337
+ if (countOnly) {
338
+ const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER);
339
+ if (!hit) continue;
340
+ totalRows += hit.rows.length;
341
+ for (const row of hit.rows) totalBytes += Buffer.byteLength(row, "utf-8") + 1;
342
+ const remaining = limit - matches;
343
+ if (remaining > 0) {
344
+ matches += Math.min(hit.matchCount, remaining);
345
+ if (hit.matchCount > remaining) limitTruncated = true;
346
+ } else {
347
+ limitTruncated = true;
348
+ }
349
+ continue;
350
+ }
279
351
  const remaining = limit - matches;
280
352
  if (remaining <= 0) {
281
353
  limitTruncated = true;
@@ -283,18 +355,32 @@ export function regGrep(pi: ExtensionAPI): void {
283
355
  }
284
356
  const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
285
357
  if (!hit) continue;
286
- const rowBudget = MAX_SHOWN_ROWS - rowCount;
287
- if (rowBudget <= 0) {
288
- rowTruncated = true;
289
- break;
358
+ const keptRows: string[] = [];
359
+ const keptHashes: string[] = [];
360
+ for (let i = 0; i < hit.rows.length; i++) {
361
+ const row = hit.rows[i]!;
362
+ const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
363
+ if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
364
+ rowTruncated = true;
365
+ if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
366
+ for (let j = i; j < hit.rows.length; j++) {
367
+ totalRows += 1;
368
+ totalBytes += Buffer.byteLength(hit.rows[j]!, "utf-8") + 1;
369
+ }
370
+ break;
371
+ }
372
+ keptRows.push(row);
373
+ keptHashes.push(hit.hashes[i]);
374
+ if (hit.fragmented[i]) linesReplaced += 1;
375
+ rowCount += 1;
376
+ byteCount += rowBytes;
377
+ totalRows += 1;
378
+ totalBytes += rowBytes;
290
379
  }
291
- const keptRows = hit.rows.slice(0, rowBudget);
292
- const keptHashes = hit.hashes.slice(0, rowBudget);
293
- rowCount += keptRows.length;
294
380
  if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
295
- if (keptRows.length < hit.rows.length) rowTruncated = true;
296
381
  matches += hit.matchCount;
297
382
  hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
383
+ if (rowTruncated) countOnly = true;
298
384
  }
299
385
  for (const hit of hits) {
300
386
  await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
@@ -303,14 +389,32 @@ export function regGrep(pi: ExtensionAPI): void {
303
389
  .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
304
390
  .join("\n");
305
391
  const notes: string[] = [];
306
- if (rowTruncated) notes.push(`[grep: output truncated at ${MAX_SHOWN_ROWS} rows; refine the pattern to see more.]`);
392
+ if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
307
393
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
308
394
  if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
395
+ if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
309
396
  const truncated = limitTruncated || rowTruncated;
397
+ const truncation: TruncationResult | undefined = rowTruncated
398
+ ? {
399
+ content: blocks,
400
+ truncated: true,
401
+ truncatedBy,
402
+ totalLines: totalRows,
403
+ totalBytes,
404
+ outputLines: rowCount,
405
+ outputBytes: byteCount,
406
+ lastLinePartial: false,
407
+ firstLineExceedsLimit: false,
408
+ maxLines: DEFAULT_MAX_LINES,
409
+ maxBytes: DEFAULT_MAX_BYTES,
410
+ }
411
+ : undefined;
310
412
  const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
311
413
  return {
312
414
  content: [{ type: "text", text }],
313
415
  details: {
416
+ ...(truncation ? { truncation } : {}),
417
+ ...(linesReplaced > 0 ? { linesTruncated: true as const } : {}),
314
418
  metrics: {
315
419
  matches,
316
420
  files: hits.length,
package/src/hash-store.ts CHANGED
@@ -66,6 +66,7 @@ interface Prepared {
66
66
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
67
67
  allPaths: (...params: SqlParams) => Record<string, unknown>[];
68
68
  allHashes: (...params: SqlParams) => Record<string, unknown>[];
69
+ allServed: (...params: SqlParams) => Record<string, unknown>[];
69
70
  deleteOne: (...params: SqlParams) => void;
70
71
  upsert: (...params: SqlParams) => void;
71
72
  undoUpsert: (...params: SqlParams) => void;
@@ -118,6 +119,14 @@ export function parseHashList(raw: string, onInvalid: () => void): string[] | un
118
119
  return parsed;
119
120
  }
120
121
 
122
+ export function parseStoredHashes(
123
+ row: Record<string, unknown> | undefined,
124
+ onInvalid: () => void,
125
+ ): string[] | undefined {
126
+ if (!row) return undefined;
127
+ return parseHashList(row.hashes as string, onInvalid);
128
+ }
129
+
121
130
  function isValidSnapshot(value: unknown): value is LegacySnapshot {
122
131
  if (typeof value !== "object" || value === null) return false;
123
132
  const v = value as Record<string, unknown>;
@@ -174,6 +183,14 @@ function openDbWithBusyRetry(storePath: string): { db: RawDb; stmts: Prepared }
174
183
  return withBusyRetry(() => openDb(storePath));
175
184
  }
176
185
 
186
+ function retriedWrite(
187
+ stmt: { run(...params: SqlParams): unknown },
188
+ ): (...params: SqlParams) => void {
189
+ return (...params) => {
190
+ withBusyRetry(() => { stmt.run(...params); });
191
+ };
192
+ }
193
+
177
194
  let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
178
195
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
179
196
  let exitHandlerRegistered = false;
@@ -238,6 +255,7 @@ function buildStore(
238
255
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
239
256
  db.exec("DELETE FROM snapshots");
240
257
  db.exec("DELETE FROM undo");
258
+ db.exec("DELETE FROM served");
241
259
  }
242
260
  db.prepare(
243
261
  "INSERT INTO meta (key, value) VALUES ('version', ?) " +
@@ -246,6 +264,7 @@ function buildStore(
246
264
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
247
265
  const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
248
266
  const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
267
+ const allServedStmt = db.prepare("SELECT path, hashes FROM served");
249
268
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
250
269
  const upsertStmt = db.prepare(
251
270
  "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
@@ -269,14 +288,15 @@ function buildStore(
269
288
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
270
289
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
271
290
  allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
272
- deleteOne: (...params) => { withBusyRetry(() => { delStmt.run(...params); }); },
273
- upsert: (...params) => { withBusyRetry(() => { upsertStmt.run(...params); }); },
274
- undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
291
+ allServed: (...params) => allServedStmt.all(...params) as Record<string, unknown>[],
292
+ deleteOne: retriedWrite(delStmt),
293
+ upsert: retriedWrite(upsertStmt),
294
+ undoUpsert: retriedWrite(undoUpsertStmt),
275
295
  undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
276
- undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
296
+ undoDelete: retriedWrite(undoDelStmt),
277
297
  servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
278
- servedUpsert: (...params) => { withBusyRetry(() => { servedUpsertStmt.run(...params); }); },
279
- servedDelete: (...params) => { withBusyRetry(() => { servedDelStmt.run(...params); }); },
298
+ servedUpsert: retriedWrite(servedUpsertStmt),
299
+ servedDelete: retriedWrite(servedDelStmt),
280
300
  };
281
301
  return { db, stmts };
282
302
  }
@@ -489,8 +509,7 @@ export function getSnapshot(
489
509
  return cached.hashes.slice();
490
510
  }
491
511
  const row = store.stmts.get(path, checksum, lineCount);
492
- if (!row) return undefined;
493
- const parsed = parseHashList(row.hashes as string, () => {
512
+ const parsed = parseStoredHashes(row, () => {
494
513
  if (deleteCorrupt) store.stmts.deleteOne(path);
495
514
  snapshotCache.delete(path);
496
515
  });
@@ -525,7 +544,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
525
544
  export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
526
545
  const row = store.stmts.undoGet(path);
527
546
  if (!row) return undefined;
528
- const parsed = parseHashList(row.hashes as string, () => store.stmts.undoDelete(path));
547
+ const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
529
548
  if (!parsed) return undefined;
530
549
  return {
531
550
  content: row.content as string,
@@ -551,7 +570,11 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
551
570
  try {
552
571
  await stat(row.path);
553
572
  return undefined;
554
- } catch {
573
+ } catch (error: unknown) {
574
+ if (errCode(error) !== "ENOENT") {
575
+ console.error("Failed to stat hash store path:", row.path, error);
576
+ return undefined;
577
+ }
555
578
  return row.path;
556
579
  }
557
580
  }),
@@ -571,14 +594,15 @@ export async function pruneMissing(store: HashStore): Promise<void> {
571
594
  for (const path of missing) {
572
595
  store.stmts.deleteOne(path);
573
596
  snapshotCache.delete(path);
574
- store.stmts.undoDelete(path);
575
597
  store.stmts.servedDelete(path);
576
598
  }
577
599
  });
578
600
  }
579
601
 
580
- export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
581
- const rows = store.stmts.allHashes() as { path: string; hashes: string }[];
602
+ function matchPathsByHashes(
603
+ rows: { path: string; hashes: string }[],
604
+ hashes: string[],
605
+ ): string[] {
582
606
  const matches: string[] = [];
583
607
  for (const row of rows) {
584
608
  try {
@@ -591,3 +615,11 @@ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[]
591
615
  }
592
616
  return matches;
593
617
  }
618
+
619
+ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
620
+ return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
621
+ }
622
+
623
+ export function findServedPaths(store: HashStore, hashes: string[]): string[] {
624
+ return matchPathsByHashes(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
625
+ }
@@ -1,5 +1,5 @@
1
1
  import { abortIf, splitLines } from "../utils";
2
- import { _lineHashesPure, HASH_SEP } from "./hash";
2
+ import { _lineHashesPure } from "./hash";
3
3
  import {
4
4
  valEdit,
5
5
  stripBarePrefixes,
@@ -268,20 +268,7 @@ export function applyEdit(
268
268
  };
269
269
  }
270
270
 
271
- export function fmtRegion(
272
- hashes: string[],
273
- lines: string[],
274
- ): string {
275
- if (hashes.length !== lines.length) {
276
- throw new Error(
277
- `fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
278
- );
279
- }
280
- return lines
281
- .map((line, index) => `${hashes[index]}${HASH_SEP}${line}`)
282
- .join("\n");
283
- }
284
-
271
+ export { fmtRegion, fmtRow } from "./resolve";
285
272
  export function changedRange(
286
273
  original: string,
287
274
  result: string,
@@ -1,4 +1,5 @@
1
- import { splitLines } from "../utils";
1
+ import { splitLines, truncateToBytes } from "../utils";
2
+ import { MAX_HASH_SOURCE_BYTES } from "../constants";
2
3
  import {
3
4
  loadHashStore,
4
5
  type HashStore,
@@ -47,10 +48,38 @@ export const HL_PREFIX_MINUS_RE = new RegExp(
47
48
 
48
49
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
49
50
 
51
+ export type RowPrefixKind = "bare" | "plus" | "minus";
52
+
53
+ export type StrippedRow = {
54
+ text: string;
55
+ kind: RowPrefixKind | null;
56
+ hash: string | undefined;
57
+ };
58
+
59
+ export function stripRowPrefix(line: string): StrippedRow {
60
+ const bare = line.match(HL_BARE_PREFIX_RE);
61
+ if (bare) {
62
+ return { text: line.slice(bare[0].length), kind: "bare", hash: bare[1] };
63
+ }
64
+ const plus = line.match(HL_PREFIX_PLUS_RE);
65
+ if (plus) {
66
+ return { text: line.slice(plus[0].length), kind: "plus", hash: plus[1] };
67
+ }
68
+ const minus = line.match(HL_PREFIX_MINUS_RE);
69
+ if (minus) {
70
+ return { text: line.slice(minus[0].length), kind: "minus", hash: minus[1] };
71
+ }
72
+ return { text: line, kind: null, hash: undefined };
73
+ }
74
+
50
75
  export function canon(line: string): string {
51
76
  return line.replace(/\r/g, "").trimEnd();
52
77
  }
53
78
 
79
+ export function hashSource(line: string): string {
80
+ return truncateToBytes(canon(line), MAX_HASH_SOURCE_BYTES);
81
+ }
82
+
54
83
  const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
55
84
 
56
85
  function getBit(bits: Uint32Array, idx: number): boolean {
@@ -93,7 +122,7 @@ export function _lineHashesPure(content: string): string[] {
93
122
  const hint = { value: 0 };
94
123
 
95
124
  for (let i = 0; i < lines.length; i++) {
96
- const c = canon(lines[i]!);
125
+ const c = hashSource(lines[i]!);
97
126
  const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
98
127
  hashes[i] = assignHash(used, baseIdx, hint);
99
128
  }
@@ -231,7 +260,7 @@ function mapStableHashes(
231
260
 
232
261
  const newByContent = new Map<string, number[]>();
233
262
  for (let i = 0; i < newLines.length; i++) {
234
- const key = canon(newLines[i]!);
263
+ const key = hashSource(newLines[i]!);
235
264
  const list = newByContent.get(key);
236
265
  if (list) list.push(i);
237
266
  else newByContent.set(key, [i]);
@@ -246,7 +275,7 @@ function mapStableHashes(
246
275
  };
247
276
 
248
277
  for (const entry of survivors) {
249
- const candidates = newByContent.get(canon(oldLines[entry.index]!));
278
+ const candidates = newByContent.get(hashSource(oldLines[entry.index]!));
250
279
  if (!candidates || candidates.length === 0) continue;
251
280
  const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
252
281
  const pos = nearestNew(candidates, target);
@@ -277,7 +306,7 @@ function mapStableHashes(
277
306
 
278
307
  for (let i = 0; i < newLines.length; i++) {
279
308
  if (newHashes[i]) continue;
280
- const c = canon(newLines[i]!);
309
+ const c = hashSource(newLines[i]!);
281
310
  const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
282
311
  newHashes[i] = assignHash(used, baseIdx, hint);
283
312
  }
@@ -11,6 +11,7 @@ export {
11
11
  _lineHashesPure,
12
12
  initHasher,
13
13
  canon,
14
+ hashSource,
14
15
  } from "./hash";
15
16
 
16
17
  export {
@@ -43,5 +44,6 @@ export {
43
44
  buildIdx,
44
45
  applyEdit,
45
46
  fmtRegion,
47
+ fmtRow,
46
48
  changedRange,
47
49
  } from "./apply";
@@ -14,11 +14,11 @@ function diagRef(ref: string): string {
14
14
  }
15
15
 
16
16
  if (/^\d+/.test(trimmed)) {
17
- return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3"): no line numbers or trailing content.`;
17
+ return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
18
18
  }
19
19
 
20
20
  if (trimmed.includes("│")) {
21
- return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char hash, drop everything from "│" onward.`;
21
+ return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
22
22
  }
23
23
 
24
24
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
@@ -1,12 +1,11 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
- import { HASH_SEP, HASH_RUN, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
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
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
8
8
  hash: string;
9
- hashMatched: boolean;
10
9
  };
11
10
 
12
11
  export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
@@ -56,7 +55,6 @@ function resAnchorFromMap(
56
55
  return {
57
56
  line: hashMatches[0]!,
58
57
  hash: ref.hash,
59
- hashMatched: true,
60
58
  };
61
59
  }
62
60
  return { ref, kind: "ambiguous", candidates: hashMatches };
@@ -74,6 +72,19 @@ function assertAligned(
74
72
  }
75
73
  }
76
74
 
75
+ export function fmtRow(hash: string, line: string): string {
76
+ return `${hash}${HASH_SEP}${line}`;
77
+ }
78
+
79
+ export function fmtRegion(hashes: string[], lines: string[]): string {
80
+ if (hashes.length !== lines.length) {
81
+ throw new Error(
82
+ `fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
83
+ );
84
+ }
85
+ return lines.map((line, index) => fmtRow(hashes[index]!, line)).join("\n");
86
+ }
87
+
77
88
  export function fmtMismatchWithHashes(
78
89
  mismatches: HMismatch[],
79
90
  fileLines: string[],
@@ -141,12 +152,12 @@ function assertItem(edit: Record<string, unknown>): void {
141
152
 
142
153
  if ("remove_from" in edit && typeof edit.remove_from !== "string") {
143
154
  throw new Error(
144
- `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char hash).`,
155
+ `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char anchor).`,
145
156
  );
146
157
  }
147
158
  if ("remove_to" in edit && typeof edit.remove_to !== "string") {
148
159
  throw new Error(
149
- `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char hash).`,
160
+ `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char anchor).`,
150
161
  );
151
162
  }
152
163
  if (!("replacement_lines" in edit)) {
@@ -157,7 +168,7 @@ function assertItem(edit: Record<string, unknown>): void {
157
168
  }
158
169
  if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
159
170
  throw new Error(
160
- `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char hashes from read output).`,
171
+ `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char anchors from read output).`,
161
172
  );
162
173
  }
163
174
  }
@@ -176,7 +187,7 @@ export function stripAnchorRow(
176
187
  ? "diff-preview marker"
177
188
  : match[1] === "-"
178
189
  ? 'leading "-" marker'
179
- : '"HASH│" prefix';
190
+ : '"anchor│" prefix';
180
191
  warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
181
192
  return match[2]!;
182
193
  }
@@ -213,10 +224,10 @@ export function stripBarePrefixes(
213
224
  const fileHashSet = new Set(fileHashes);
214
225
  const stripped: { lineIndex: number; matched: boolean }[] = [];
215
226
  const contentLines = edit.content_lines.map((line, lineIndex) => {
216
- const match = line.match(HL_BARE_PREFIX_RE);
217
- if (!match) return line;
218
- stripped.push({ lineIndex, matched: fileHashSet.has(match[1]!) });
219
- return line.slice(match[0].length);
227
+ const result = stripRowPrefix(line);
228
+ if (result.kind !== "bare") return line;
229
+ stripped.push({ lineIndex, matched: fileHashSet.has(result.hash ?? "") });
230
+ return result.text;
220
231
  });
221
232
  if (stripped.length === 0) return edit;
222
233
  const locations = stripped
@@ -228,7 +239,7 @@ export function stripBarePrefixes(
228
239
  ? " Verify it was pasted from read output."
229
240
  : "";
230
241
  warnings.push(
231
- `[E_BARE_HASH_PREFIX] Stripped "HASH│" prefix from ${locations}.${guidance}`
242
+ `[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.${guidance}`
232
243
  );
233
244
  return { ...edit, content_lines: contentLines };
234
245
  }
@@ -239,17 +250,10 @@ export function stripDiffPrefixes(
239
250
  ): HEdit {
240
251
  const stripped: number[] = [];
241
252
  const contentLines = edit.content_lines.map((line, lineIndex) => {
242
- const plus = line.match(HL_PREFIX_PLUS_RE);
243
- if (plus) {
244
- stripped.push(lineIndex);
245
- return line.slice(plus[0].length);
246
- }
247
- const minus = line.match(HL_PREFIX_MINUS_RE);
248
- if (minus) {
249
- stripped.push(lineIndex);
250
- return line.slice(minus[0].length);
251
- }
252
- return line;
253
+ const result = stripRowPrefix(line);
254
+ if (result.kind !== "plus" && result.kind !== "minus") return line;
255
+ stripped.push(lineIndex);
256
+ return result.text;
253
257
  });
254
258
  if (stripped.length === 0) return edit;
255
259
  const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
@@ -502,12 +506,10 @@ export function resolveAnchorLine(
502
506
  }
503
507
 
504
508
  export class RangeStaleError extends Error {
505
- readonly firstMismatchLine: number;
506
509
  readonly rangeHashes: string[];
507
- constructor(message: string, firstMismatchLine: number, rangeHashes: string[]) {
510
+ constructor(message: string, rangeHashes: string[]) {
508
511
  super(message);
509
512
  this.name = "RangeStaleError";
510
- this.firstMismatchLine = firstMismatchLine;
511
513
  this.rangeHashes = rangeHashes;
512
514
  }
513
515
  }
@@ -544,7 +546,7 @@ export function assertRangeServed(
544
546
  for (let line = startLine; line < startLine + shownLength; line++) {
545
547
  const hash = fileHashes[line - 1]!;
546
548
  shownHashes.push(hash);
547
- rows.push(`${hash}${HASH_SEP}${fileLines[line - 1]}`);
549
+ rows.push(fmtRow(hash, clipLine(fileLines[line - 1])));
548
550
  }
549
551
  const location = filePath ? ` in ${filePath}` : "";
550
552
  const first = mismatchLines[0]!;
@@ -558,7 +560,7 @@ export function assertRangeServed(
558
560
  : "";
559
561
  const message =
560
562
  `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
561
- throw new RangeStaleError(message, first, shownHashes);
563
+ throw new RangeStaleError(message, shownHashes);
562
564
  }
563
565
 
564
566
  export { warnUnicodeEsc };
package/src/insert.ts CHANGED
@@ -34,7 +34,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
34
34
  throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
35
35
  }
36
36
  if (typeof request.anchor !== "string" || request.anchor.length === 0) {
37
- throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char hash from read output).');
37
+ throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char anchor from read output).');
38
38
  }
39
39
  if (request.direction !== "before" && request.direction !== "after") {
40
40
  throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
@@ -52,7 +52,7 @@ const insertToolSchema = Type.Object(
52
52
  }),
53
53
  anchor: Type.String({
54
54
  description:
55
- 'Bare 3-char HASH only (e.g. "aB3"): copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or a `HASH│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
55
+ 'Bare 3-char anchor only (e.g. "aB3"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
56
56
  }),
57
57
  direction: Type.Union(
58
58
  [