pi-hashline-edit-pro 2.8.0 → 2.8.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 +9 -4
- package/index.ts +14 -8
- package/package.json +9 -9
- package/prompts/grep-guidelines.md +3 -3
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/src/commit.ts +8 -3
- package/src/constants.ts +1 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +6 -2
- package/src/file-reader.ts +9 -2
- package/src/fs-write.ts +42 -11
- package/src/grep.ts +284 -98
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +93 -0
- package/src/hash-store.ts +90 -147
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/resolve.ts +52 -36
- package/src/insert.ts +7 -23
- package/src/normalize.ts +27 -0
- package/src/read.ts +5 -3
- package/src/replace-diff.ts +8 -30
- package/src/replace-undo.ts +22 -7
- package/src/replace.ts +14 -30
- package/src/served.ts +56 -27
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +4 -4
package/src/hash-store.ts
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import { readFile, rename, mkdir, stat } from "fs/promises";
|
|
2
|
+
import { chmod, readFile, rename, mkdir, stat } from "fs/promises";
|
|
3
3
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
4
4
|
import { errCode, isRec, splitLines } from "./utils";
|
|
5
5
|
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
6
|
-
import { HASH_RE } from "./hashline/alphabet";
|
|
7
6
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
isValidHashList,
|
|
9
|
+
isValidServedMap,
|
|
10
|
+
parseStoredHashes,
|
|
11
|
+
parseStoredServed,
|
|
12
|
+
isValidSnapshot,
|
|
13
|
+
isCorruptionError,
|
|
14
|
+
parseHashList,
|
|
15
|
+
parseServedMap,
|
|
16
|
+
} from "./hash-store/validation";
|
|
17
|
+
import {
|
|
18
|
+
withBusyRetry,
|
|
19
|
+
retriedWrite,
|
|
20
|
+
openDbWithBusyRetryAsync,
|
|
21
|
+
} from "./hash-store/retry";
|
|
22
|
+
import {
|
|
23
|
+
snapshotCache,
|
|
24
|
+
cacheSnapshot,
|
|
25
|
+
SNAPSHOT_CACHE_LIMIT,
|
|
26
|
+
} from "./hash-store/cache";
|
|
27
|
+
|
|
28
|
+
export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parseStoredHashes, parseStoredServed, isCorruptionError };
|
|
29
|
+
export { SNAPSHOT_CACHE_LIMIT };
|
|
9
30
|
export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
|
|
10
31
|
|
|
11
32
|
type SqlParams = (string | number)[];
|
|
@@ -92,133 +113,10 @@ export interface UndoRecord {
|
|
|
92
113
|
resultContent: string;
|
|
93
114
|
}
|
|
94
115
|
|
|
95
|
-
interface LegacySnapshot {
|
|
96
|
-
content: string;
|
|
97
|
-
hashes: string[];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function isValidHashList(value: unknown): value is string[] {
|
|
101
|
-
if (!Array.isArray(value)) return false;
|
|
102
|
-
for (const hash of value) {
|
|
103
|
-
if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
|
|
104
|
-
}
|
|
105
|
-
if (new Set(value).size !== value.length) return false;
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
110
|
-
let parsed: unknown;
|
|
111
|
-
try {
|
|
112
|
-
parsed = JSON.parse(raw);
|
|
113
|
-
} catch (error) {
|
|
114
|
-
console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
|
|
115
|
-
onInvalid();
|
|
116
|
-
return undefined;
|
|
117
|
-
}
|
|
118
|
-
if (!isValidHashList(parsed)) {
|
|
119
|
-
console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
120
|
-
onInvalid();
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
return parsed;
|
|
124
|
-
}
|
|
125
|
-
export function parseStoredHashes(
|
|
126
|
-
row: Record<string, unknown> | undefined,
|
|
127
|
-
onInvalid: () => void,
|
|
128
|
-
): string[] | undefined {
|
|
129
|
-
if (!row) return undefined;
|
|
130
|
-
return parseHashList(row.hashes as string, onInvalid);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
134
|
-
if (typeof value !== "object" || value === null) return false;
|
|
135
|
-
const v = value as Record<string, unknown>;
|
|
136
|
-
if (typeof v.content !== "string") return false;
|
|
137
|
-
return isValidHashList(v.hashes);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function isCorruptionError(error: unknown): boolean {
|
|
141
|
-
if (error && typeof error === "object") {
|
|
142
|
-
const errcode = (error as { errcode?: unknown }).errcode;
|
|
143
|
-
if (typeof errcode === "number") {
|
|
144
|
-
return errcode === 11 || errcode === 24 || errcode === 26;
|
|
145
|
-
}
|
|
146
|
-
const code = (error as { code?: unknown }).code;
|
|
147
|
-
if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
|
|
148
|
-
}
|
|
149
|
-
return (
|
|
150
|
-
error instanceof Error &&
|
|
151
|
-
/corrupt|not a database|malformed|database disk image/i.test(error.message)
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function isBusyError(error: unknown): boolean {
|
|
156
|
-
if (error && typeof error === "object") {
|
|
157
|
-
const errcode = (error as { errcode?: unknown }).errcode;
|
|
158
|
-
if (typeof errcode === "number") return errcode === 5 || errcode === 6;
|
|
159
|
-
}
|
|
160
|
-
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const sleepSab = new Int32Array(new SharedArrayBuffer(4));
|
|
164
|
-
|
|
165
|
-
function sleepSync(ms: number): void {
|
|
166
|
-
Atomics.wait(sleepSab, 0, 0, ms);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const BUSY_RETRIES = 3;
|
|
170
|
-
const BUSY_RETRY_DELAY_MS = 50;
|
|
171
|
-
|
|
172
|
-
function withBusyRetry<T>(fn: () => T): T {
|
|
173
|
-
let lastError: unknown;
|
|
174
|
-
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
175
|
-
try {
|
|
176
|
-
return fn();
|
|
177
|
-
} catch (error) {
|
|
178
|
-
lastError = error;
|
|
179
|
-
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
180
|
-
sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
throw lastError;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
187
|
-
let lastError: unknown;
|
|
188
|
-
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
189
|
-
try {
|
|
190
|
-
return fn();
|
|
191
|
-
} catch (error) {
|
|
192
|
-
lastError = error;
|
|
193
|
-
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
194
|
-
await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
throw lastError;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function openDbWithBusyRetryAsync(storePath: string): Promise<{ db: RawDb; stmts: Prepared }> {
|
|
201
|
-
return withBusyRetryAsync(() => openDb(storePath));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function retriedWrite(
|
|
205
|
-
stmt: { run(...params: SqlParams): unknown },
|
|
206
|
-
): (...params: SqlParams) => void {
|
|
207
|
-
return (...params) => {
|
|
208
|
-
withBusyRetry(() => { stmt.run(...params); });
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
116
|
let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
|
|
213
117
|
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
214
118
|
let exitHandlerRegistered = false;
|
|
215
|
-
|
|
216
|
-
checksum: string;
|
|
217
|
-
lineCount: number;
|
|
218
|
-
hashes: string[];
|
|
219
|
-
}
|
|
220
|
-
const snapshotCache = new Map<string, SnapshotCacheEntry>();
|
|
221
|
-
export const SNAPSHOT_CACHE_LIMIT = 256;
|
|
119
|
+
|
|
222
120
|
function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
223
121
|
const db = openDbFn(storePath);
|
|
224
122
|
try {
|
|
@@ -231,9 +129,7 @@ function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
|
231
129
|
}
|
|
232
130
|
}
|
|
233
131
|
|
|
234
|
-
function buildStore(
|
|
235
|
-
db: RawDb,
|
|
236
|
-
): { db: RawDb; stmts: Prepared } {
|
|
132
|
+
function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
237
133
|
db.exec("PRAGMA journal_mode = WAL");
|
|
238
134
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
239
135
|
db.exec(
|
|
@@ -351,29 +247,58 @@ function shutdownDb(db: RawDb): void {
|
|
|
351
247
|
}
|
|
352
248
|
|
|
353
249
|
async function openStore(storePath: string): Promise<HashStore> {
|
|
354
|
-
|
|
355
|
-
|
|
250
|
+
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
251
|
+
return { stmts: cachedDb.stmts, engine: sqliteEngine };
|
|
252
|
+
}
|
|
253
|
+
if (cachedDb) shutdownHashStore();
|
|
356
254
|
await initHasher();
|
|
357
|
-
await mkdir(hashStoreDir(), { recursive: true });
|
|
255
|
+
await mkdir(hashStoreDir(), { recursive: true, mode: 0o700 });
|
|
256
|
+
if (process.platform !== "win32") {
|
|
257
|
+
await chmod(hashStoreDir(), 0o700);
|
|
258
|
+
}
|
|
358
259
|
|
|
359
260
|
let existed = existsSync(storePath);
|
|
360
261
|
let opened: { db: RawDb; stmts: Prepared };
|
|
361
262
|
try {
|
|
362
|
-
opened = await openDbWithBusyRetryAsync(storePath);
|
|
263
|
+
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
363
264
|
} catch (error) {
|
|
364
265
|
if (!isCorruptionError(error)) throw error;
|
|
365
266
|
console.error("Hash store failed to open, rebuilding:", error);
|
|
366
267
|
await quarantineStore(storePath);
|
|
367
268
|
existed = false;
|
|
368
|
-
opened = await openDbWithBusyRetryAsync(storePath);
|
|
269
|
+
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
369
270
|
}
|
|
370
271
|
if (!isHealthy(opened.db)) {
|
|
371
272
|
shutdownDb(opened.db);
|
|
372
273
|
await quarantineStore(storePath);
|
|
373
274
|
existed = false;
|
|
374
|
-
opened = await openDbWithBusyRetryAsync(storePath);
|
|
275
|
+
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
375
276
|
}
|
|
376
277
|
const { db, stmts } = opened;
|
|
278
|
+
try {
|
|
279
|
+
const autoVacuum = (db.prepare("PRAGMA auto_vacuum").get() as { auto_vacuum: number }).auto_vacuum;
|
|
280
|
+
const pageCount = (db.prepare("PRAGMA page_count").get() as { page_count: number }).page_count;
|
|
281
|
+
const freelist = (db.prepare("PRAGMA freelist_count").get() as { freelist_count: number }).freelist_count;
|
|
282
|
+
if (autoVacuum === 0 && !existed) {
|
|
283
|
+
db.exec("PRAGMA auto_vacuum=INCREMENTAL");
|
|
284
|
+
} else if (freelist > 50 && freelist * 5 > pageCount) {
|
|
285
|
+
try {
|
|
286
|
+
db.exec("PRAGMA incremental_vacuum(50)");
|
|
287
|
+
} catch {
|
|
288
|
+
db.exec("VACUUM");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch {}
|
|
292
|
+
|
|
293
|
+
if (process.platform !== "win32") {
|
|
294
|
+
for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
|
|
295
|
+
try {
|
|
296
|
+
await chmod(candidate, 0o600);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
377
302
|
|
|
378
303
|
if (!existed) {
|
|
379
304
|
try {
|
|
@@ -422,7 +347,7 @@ export function shutdownHashStore(): void {
|
|
|
422
347
|
}
|
|
423
348
|
|
|
424
349
|
export function withStore(fn: () => void): void {
|
|
425
|
-
if (!cachedDb) {
|
|
350
|
+
if (!cachedDb || !cachedDb.db.isOpen) {
|
|
426
351
|
throw new Error(STORE_NOT_OPEN_MESSAGE);
|
|
427
352
|
}
|
|
428
353
|
withBusyRetry(() => {
|
|
@@ -503,15 +428,6 @@ async function migrateLegacy(db: RawDb): Promise<void> {
|
|
|
503
428
|
}
|
|
504
429
|
}
|
|
505
430
|
|
|
506
|
-
function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
|
|
507
|
-
snapshotCache.delete(path);
|
|
508
|
-
snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
|
|
509
|
-
if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
|
|
510
|
-
const oldest = snapshotCache.keys().next().value;
|
|
511
|
-
if (oldest !== undefined) snapshotCache.delete(oldest);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
431
|
export function getSnapshot(
|
|
516
432
|
store: HashStore,
|
|
517
433
|
path: string,
|
|
@@ -652,10 +568,37 @@ function matchPathsByHashes(
|
|
|
652
568
|
return matches;
|
|
653
569
|
}
|
|
654
570
|
|
|
571
|
+
function matchPathsByServed(
|
|
572
|
+
rows: { path: string; hashes: string }[],
|
|
573
|
+
hashes: string[],
|
|
574
|
+
): string[] {
|
|
575
|
+
const needed = new Set(hashes);
|
|
576
|
+
if (needed.size === 0) return [];
|
|
577
|
+
const matches: string[] = [];
|
|
578
|
+
for (const row of rows) {
|
|
579
|
+
try {
|
|
580
|
+
const parsed = JSON.parse(row.hashes) as unknown;
|
|
581
|
+
if (!isValidServedMap(parsed)) continue;
|
|
582
|
+
const keySet = new Set(Object.keys(parsed as Record<string, unknown>));
|
|
583
|
+
let ok = true;
|
|
584
|
+
for (const h of needed) {
|
|
585
|
+
if (!keySet.has(h)) {
|
|
586
|
+
ok = false;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (ok) matches.push(row.path);
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return matches;
|
|
596
|
+
}
|
|
597
|
+
|
|
655
598
|
export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
|
|
656
599
|
return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
|
|
657
600
|
}
|
|
658
601
|
|
|
659
602
|
export function findServedPaths(store: HashStore, hashes: string[]): string[] {
|
|
660
|
-
return
|
|
603
|
+
return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
|
|
661
604
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -154,7 +154,7 @@ export function applyEdit(
|
|
|
154
154
|
signal?: AbortSignal,
|
|
155
155
|
precomputedHashes?: string[],
|
|
156
156
|
filePath?: string,
|
|
157
|
-
servedHashes?:
|
|
157
|
+
servedHashes?: ReadonlyMap<string, string>,
|
|
158
158
|
skipBoundaryDedup?: boolean,
|
|
159
159
|
): {
|
|
160
160
|
content: string;
|
|
@@ -190,7 +190,7 @@ export function applyEdit(
|
|
|
190
190
|
fileHashes,
|
|
191
191
|
filePath,
|
|
192
192
|
);
|
|
193
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
193
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
warnUnicodeEsc(prefixFixed, warnings);
|
|
@@ -233,7 +233,7 @@ export function applyEdit(
|
|
|
233
233
|
fileHashes,
|
|
234
234
|
filePath,
|
|
235
235
|
);
|
|
236
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
236
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
237
237
|
}
|
|
238
238
|
resolved = correctedResult.resolved;
|
|
239
239
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
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)
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
492
|
+
ref: Anchor,
|
|
493
|
+
fileLines: string[],
|
|
494
|
+
fileHashes: string[],
|
|
495
|
+
filePath?: string,
|
|
489
496
|
): number {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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/insert.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { Type } from "typebox";
|
|
4
3
|
import { constants } from "fs";
|
|
5
4
|
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
6
5
|
import { commitEdit } from "./commit";
|
|
7
6
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { resolveInCwd } from "./fs-write";
|
|
9
7
|
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
8
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
9
|
import { loadP, loadGuide } from "./prompts";
|
|
12
10
|
import { normReq } from "./payload-contract";
|
|
13
|
-
import {
|
|
14
|
-
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
11
|
+
import { isRec, rejectUnknownFields, splitLines } from "./utils";
|
|
15
12
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
|
+
import type { RPreview, RRState } from "./replace-render";
|
|
14
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
16
15
|
|
|
17
16
|
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
18
17
|
|
|
@@ -158,32 +157,17 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
158
157
|
description: loadP("../prompts/insert.md"),
|
|
159
158
|
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
160
159
|
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
161
|
-
|
|
162
|
-
executionMode: "sequential",
|
|
160
|
+
...editToolBase,
|
|
163
161
|
parameters: insertToolSchema,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
167
|
-
return renderEditResult(
|
|
168
|
-
result as {
|
|
169
|
-
content?: Array<{ type: string; text?: string }>;
|
|
170
|
-
details?: ReplaceDetails;
|
|
171
|
-
},
|
|
172
|
-
{ isPartial, expanded },
|
|
173
|
-
theme,
|
|
174
|
-
context,
|
|
175
|
-
);
|
|
176
|
-
},
|
|
177
|
-
|
|
162
|
+
renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
|
|
163
|
+
renderResult: editRenderResultWrapper,
|
|
178
164
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
179
165
|
const canonical = normReq(params);
|
|
180
166
|
assertInsertReq(canonical);
|
|
181
167
|
const req = canonical;
|
|
182
168
|
const path = req.path;
|
|
183
169
|
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
184
|
-
|
|
185
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
186
|
-
abortIf(signal);
|
|
170
|
+
return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
187
171
|
const preload = await readNormFile(path, ctx.cwd, {
|
|
188
172
|
signal,
|
|
189
173
|
accessMode: constants.R_OK | constants.W_OK,
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
2
|
+
|
|
3
|
+
export function detectEnding(content: string): LineEnding {
|
|
4
|
+
const crIdx = content.indexOf("\r");
|
|
5
|
+
const lfIdx = content.indexOf("\n");
|
|
6
|
+
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
7
|
+
if (crIdx === -1) return "\n";
|
|
8
|
+
if (lfIdx === -1) return "\r";
|
|
9
|
+
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
10
|
+
return "\n";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toLF(text: string): string {
|
|
14
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function restoreEndings(text: string, ending: LineEnding): string {
|
|
18
|
+
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
19
|
+
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stripBOM(content: string): { bom: string; text: string } {
|
|
24
|
+
return content.startsWith("\uFEFF")
|
|
25
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
26
|
+
: { bom: "", text: content };
|
|
27
|
+
}
|
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
|
-
|
|
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
|
package/src/replace-diff.ts
CHANGED
|
@@ -5,37 +5,15 @@ import {
|
|
|
5
5
|
ANCHOR_LEN,
|
|
6
6
|
HASH_SEP,
|
|
7
7
|
} from "./hashline";
|
|
8
|
+
import {
|
|
9
|
+
detectEnding,
|
|
10
|
+
toLF,
|
|
11
|
+
restoreEndings,
|
|
12
|
+
stripBOM,
|
|
13
|
+
type LineEnding,
|
|
14
|
+
} from "./normalize";
|
|
8
15
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
export function detectEnding(content: string): LineEnding {
|
|
12
|
-
const crIdx = content.indexOf("\r");
|
|
13
|
-
const lfIdx = content.indexOf("\n");
|
|
14
|
-
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
15
|
-
if (crIdx === -1) return "\n";
|
|
16
|
-
if (lfIdx === -1) return "\r";
|
|
17
|
-
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
18
|
-
return "\n";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function toLF(text: string): string {
|
|
22
|
-
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function restoreEndings(
|
|
26
|
-
text: string,
|
|
27
|
-
ending: LineEnding,
|
|
28
|
-
): string {
|
|
29
|
-
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
30
|
-
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
31
|
-
return text;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function stripBOM(content: string): { bom: string; text: string } {
|
|
35
|
-
return content.startsWith("\uFEFF")
|
|
36
|
-
? { bom: "\uFEFF", text: content.slice(1) }
|
|
37
|
-
: { bom: "", text: content };
|
|
38
|
-
}
|
|
16
|
+
export { detectEnding, toLF, restoreEndings, stripBOM, type LineEnding };
|
|
39
17
|
|
|
40
18
|
function fmtDiffLine(
|
|
41
19
|
prefix: " " | "+" | "-",
|