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/README.md +37 -35
- package/index.ts +8 -8
- package/package.json +1 -1
- package/prompts/grep-guidelines.md +3 -2
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -2
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -4
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +4 -2
- package/prompts/undo-last-change.md +1 -1
- package/src/boundary-bypass.ts +2 -10
- package/src/constants.ts +4 -2
- package/src/grep.ts +120 -16
- package/src/hash-store.ts +45 -13
- package/src/hashline/apply.ts +2 -15
- package/src/hashline/hash.ts +34 -5
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +2 -2
- package/src/hashline/resolve.ts +30 -28
- package/src/insert.ts +2 -2
- package/src/read.ts +17 -10
- package/src/replace-diff.ts +92 -8
- package/src/replace-render.ts +32 -6
- package/src/replace-response.ts +5 -7
- package/src/replace-undo.ts +18 -25
- package/src/replace.ts +5 -5
- package/src/served.ts +2 -3
- package/src/utils.ts +13 -0
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 ${
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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: (
|
|
296
|
+
undoDelete: retriedWrite(undoDelStmt),
|
|
277
297
|
servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
278
|
-
servedUpsert: (
|
|
279
|
-
servedDelete: (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
581
|
-
|
|
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
|
+
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf, splitLines } from "../utils";
|
|
2
|
-
import { _lineHashesPure
|
|
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
|
|
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,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
}
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -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
|
|
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
|
|
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").`;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
-
import { HASH_SEP, HASH_RUN,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
: '"
|
|
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
|
|
217
|
-
if (
|
|
218
|
-
stripped.push({ lineIndex, matched: fileHashSet.has(
|
|
219
|
-
return
|
|
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 "
|
|
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
|
|
243
|
-
if (plus)
|
|
244
|
-
|
|
245
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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
|
[
|