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/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
- interface SnapshotCacheEntry {
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
- shutdownHashStore();
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 matchPathsByHashes(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
603
+ return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
661
604
  }
@@ -154,7 +154,7 @@ export function applyEdit(
154
154
  signal?: AbortSignal,
155
155
  precomputedHashes?: string[],
156
156
  filePath?: string,
157
- servedHashes?: ReadonlySet<string>,
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
  }
@@ -2,6 +2,7 @@ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, cl
2
2
  import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
+ import { contentChecksum } from "./hasher";
5
6
 
6
7
  export type RAnchor = {
7
8
  line: number;
@@ -90,14 +91,13 @@ export function fmtMismatchWithHashes(
90
91
  fileLines: string[],
91
92
  fileHashes: string[],
92
93
  filePath?: string,
93
- ): { text: string; hashes: string[] } {
94
+ ): { text: string; hashes: string[]; servedMap: Map<string, string> } {
94
95
  assertAligned(fileLines, fileHashes, "fmtMismatch");
95
-
96
96
  const out: string[] = [];
97
97
  const hashes: string[] = [];
98
+ const servedMap = new Map<string, string>();
98
99
  const notFound = mismatches.filter((m) => m.kind === "not_found");
99
100
  const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
100
-
101
101
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
102
102
  if (notFound.length > 0) {
103
103
  out.push(
@@ -110,8 +110,11 @@ export function fmtMismatchWithHashes(
110
110
  const to = Math.min(fileLines.length, ctx.line + 1);
111
111
  const rows: string[] = [];
112
112
  for (let ln = from; ln <= to; ln++) {
113
- hashes.push(fileHashes[ln - 1]!);
114
- rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
113
+ const h = fileHashes[ln - 1]!;
114
+ const c = fileLines[ln - 1] ?? "";
115
+ hashes.push(h);
116
+ servedMap.set(h, contentChecksum(c));
117
+ rows.push(` ${ln}: ${h}│${clipLine(c)}`);
115
118
  }
116
119
  out.push("");
117
120
  out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
@@ -128,7 +131,12 @@ export function fmtMismatchWithHashes(
128
131
  (m.candidates?.length ?? 0) > sample.length
129
132
  ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
130
133
  : "";
131
- for (const line of sample) hashes.push(fileHashes[line - 1]!);
134
+ for (const line of sample) {
135
+ const h = fileHashes[line - 1]!;
136
+ const c = fileLines[line - 1] ?? "";
137
+ hashes.push(h);
138
+ servedMap.set(h, contentChecksum(c));
139
+ }
132
140
  const lines = sample
133
141
  .map((line) => {
134
142
  const content = clipLine(fileLines[line - 1] ?? "");
@@ -140,8 +148,7 @@ export function fmtMismatchWithHashes(
140
148
  );
141
149
  }
142
150
  }
143
-
144
- return { text: out.join("\n"), hashes };
151
+ return { text: out.join("\n"), hashes, servedMap };
145
152
  }
146
153
 
147
154
 
@@ -482,45 +489,49 @@ export function valEdit(
482
489
  }
483
490
 
484
491
  export function resolveAnchorLine(
485
- ref: Anchor,
486
- fileLines: string[],
487
- fileHashes: string[],
488
- filePath?: string,
492
+ ref: Anchor,
493
+ fileLines: string[],
494
+ fileHashes: string[],
495
+ filePath?: string,
489
496
  ): number {
490
- const { resolved, mismatches } = valEdit(
491
- { hash_bounds: [ref, ref], content_lines: [] },
492
- fileLines,
493
- fileHashes,
494
- [],
495
- undefined,
496
- );
497
- if (mismatches.length > 0 || !resolved) {
498
- const feedback = fmtMismatchWithHashes(
499
- mismatches,
500
- fileLines,
501
- fileHashes,
502
- filePath,
503
- );
504
- throw new AnchorMismatchError(feedback.text, feedback.hashes);
505
- }
506
- return resolved.hash_bounds[0].line;
497
+ const { resolved, mismatches } = valEdit(
498
+ { hash_bounds: [ref, ref], content_lines: [] },
499
+ fileLines,
500
+ fileHashes,
501
+ [],
502
+ undefined,
503
+ );
504
+ if (mismatches.length > 0 || !resolved) {
505
+ const feedback = fmtMismatchWithHashes(
506
+ mismatches,
507
+ fileLines,
508
+ fileHashes,
509
+ filePath,
510
+ );
511
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
512
+ }
513
+ return resolved.hash_bounds[0].line;
507
514
  }
508
515
 
509
516
  export class RangeStaleError extends Error {
510
517
  readonly rangeHashes: string[];
511
- constructor(message: string, rangeHashes: string[]) {
518
+ readonly rangeServedMap: Map<string, string>;
519
+ constructor(message: string, rangeHashes: string[], rangeServedMap: Map<string, string>) {
512
520
  super(message);
513
521
  this.name = "RangeStaleError";
514
522
  this.rangeHashes = rangeHashes;
523
+ this.rangeServedMap = rangeServedMap;
515
524
  }
516
525
  }
517
526
 
518
527
  export class AnchorMismatchError extends Error {
519
528
  readonly feedbackHashes: string[];
520
- constructor(message: string, feedbackHashes: string[]) {
529
+ readonly feedbackMap: Map<string, string>;
530
+ constructor(message: string, feedbackHashes: string[], feedbackMap: Map<string, string>) {
521
531
  super(message);
522
532
  this.name = "AnchorMismatchError";
523
533
  this.feedbackHashes = feedbackHashes;
534
+ this.feedbackMap = feedbackMap;
524
535
  }
525
536
  }
526
537
 
@@ -528,7 +539,7 @@ export function assertRangeServed(
528
539
  resolved: RHEdit,
529
540
  fileLines: string[],
530
541
  fileHashes: string[],
531
- served: ReadonlySet<string>,
542
+ served: ReadonlyMap<string, string> | undefined,
532
543
  filePath?: string,
533
544
  ): void {
534
545
  assertAligned(fileLines, fileHashes, "assertRangeServed");
@@ -536,18 +547,23 @@ export function assertRangeServed(
536
547
  const endLine = resolved.hash_bounds[1].line;
537
548
  const mismatchLines: number[] = [];
538
549
  for (let line = startLine; line <= endLine; line++) {
539
- if (!served.has(fileHashes[line - 1]!)) mismatchLines.push(line);
550
+ const hash = fileHashes[line - 1]!;
551
+ const content = fileLines[line - 1]!;
552
+ const servedContent = served?.get(hash);
553
+ if (servedContent === undefined || servedContent !== contentChecksum(content)) mismatchLines.push(line);
540
554
  }
541
555
  if (mismatchLines.length === 0) return;
542
-
543
556
  const rangeLength = endLine - startLine + 1;
544
557
  const shownLength = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
545
558
  const rows: string[] = [];
546
559
  const shownHashes: string[] = [];
560
+ const shownMap = new Map<string, string>();
547
561
  for (let line = startLine; line < startLine + shownLength; line++) {
548
562
  const hash = fileHashes[line - 1]!;
563
+ const content = fileLines[line - 1]!;
549
564
  shownHashes.push(hash);
550
- rows.push(fmtRow(hash, clipLine(fileLines[line - 1])));
565
+ shownMap.set(hash, contentChecksum(content));
566
+ rows.push(fmtRow(hash, clipLine(content)));
551
567
  }
552
568
  const location = filePath ? ` in ${filePath}` : "";
553
569
  const first = mismatchLines[0]!;
@@ -561,7 +577,7 @@ export function assertRangeServed(
561
577
  : "";
562
578
  const message =
563
579
  `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
564
- throw new RangeStaleError(message, shownHashes);
580
+ throw new RangeStaleError(message, shownHashes, shownMap);
565
581
  }
566
582
 
567
583
  export { warnUnicodeEsc };
package/src/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 { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
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
- prepareArguments: makePrepareArguments(),
162
- executionMode: "sequential",
160
+ ...editToolBase,
163
161
  parameters: insertToolSchema,
164
- renderShell: "default",
165
- renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
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
- const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
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,
@@ -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
- await recordServedSafe(resolvedPath, preview.servedHashes, "read", new Set(fileHashes));
239
+ const fileLines = splitLines(normalized);
240
+ const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
241
+ await recordServedSafe(resolvedPath, servedMap, "read", new Set(fileHashes));
240
242
  const snapshotId = await safeSnapId(absolutePath, "read");
241
243
  const previewText =
242
244
  hadUtf8DecodeErrors
@@ -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 type LineEnding = "\r\n" | "\n" | "\r";
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: " " | "+" | "-",