pi-hashline-edit-pro 3.0.3 → 4.0.0

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.
@@ -0,0 +1,605 @@
1
+ import { appendFile, mkdir, readFile, readdir, rm, stat } from "fs/promises";
2
+ import { join } from "path";
3
+ import { createHash } from "crypto";
4
+ import { sessionClaimsDir } from "./paths";
5
+ import { contentChecksum } from "./hashline/hasher";
6
+ import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
7
+ import { HASH_PROBE_STRIDE } from "./hashline/hash";
8
+ import { errCode, splitLines } from "./utils";
9
+ import { hashSource } from "./hashline";
10
+ import * as Diff from "diff";
11
+ import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
12
+ import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
13
+
14
+ export type RegistryEvent =
15
+ | { kind: "session"; sessionFile: string }
16
+ | { kind: "allocate"; path: string; rows: [string, string][] }
17
+ | { kind: "free"; path: string; anchors?: string[] }
18
+ | { kind: "clear" };
19
+
20
+ export interface OwnedAnchor {
21
+ path: string;
22
+ checksum: string;
23
+ }
24
+
25
+ interface SessionState {
26
+ owned: Map<string, OwnedAnchor>;
27
+ served: Map<string, Map<string, string>>;
28
+ everMinted: Set<string>;
29
+ probe: number;
30
+ }
31
+
32
+ const SIDECAR_SUFFIX = ".registry.jsonl";
33
+
34
+ let currentKey: string | undefined;
35
+ let currentSidecar: string | undefined;
36
+ const registries = new Map<string, SessionState>();
37
+
38
+ function newSessionState(seed?: string): SessionState {
39
+ let probe = 0;
40
+ if (seed) {
41
+ for (const byte of createHash("sha256").update(seed).digest()) {
42
+ probe = (probe * 256 + byte) % ANCHOR_COUNT;
43
+ }
44
+ }
45
+ return { owned: new Map(), served: new Map(), everMinted: new Set(), probe };
46
+ }
47
+
48
+ function seedServedFromOwned(state: SessionState): void {
49
+ for (const [anchor, entry] of state.owned) {
50
+ let served = state.served.get(entry.path);
51
+ if (!served) {
52
+ served = new Map();
53
+ state.served.set(entry.path, served);
54
+ }
55
+ served.set(anchor, entry.checksum);
56
+ }
57
+ }
58
+
59
+ export function foldRegistryEvents(events: RegistryEvent[]): SessionState {
60
+ const state = newSessionState();
61
+ for (const event of events) {
62
+ if (event.kind === "clear") {
63
+ state.owned.clear();
64
+ } else if (event.kind === "allocate") {
65
+ for (const [anchor, checksum] of event.rows) {
66
+ state.owned.set(anchor, { path: event.path, checksum });
67
+ }
68
+ } else if (event.kind === "free") {
69
+ if (event.anchors) {
70
+ for (const anchor of event.anchors) {
71
+ state.owned.delete(anchor);
72
+ }
73
+ } else {
74
+ for (const [anchor, entry] of [...state.owned]) {
75
+ if (entry.path === event.path) {
76
+ state.owned.delete(anchor);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return state;
83
+ }
84
+
85
+ export function parseRegistryLog(raw: string): RegistryEvent[] {
86
+ const events: RegistryEvent[] = [];
87
+ for (const line of raw.split("\n")) {
88
+ if (!line.trim()) continue;
89
+ try {
90
+ const parsed = JSON.parse(line) as RegistryEvent;
91
+ if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session")) {
92
+ events.push(parsed);
93
+ }
94
+ } catch {
95
+ continue;
96
+ }
97
+ }
98
+ return events;
99
+ }
100
+
101
+ function sidecarPath(key: string): string {
102
+ return join(sessionClaimsDir(), `${key}${SIDECAR_SUFFIX}`);
103
+ }
104
+
105
+ function sidecarKeyFor(sessionFile: string): string {
106
+ return createHash("sha256").update(sessionFile).digest("hex").slice(0, 24);
107
+ }
108
+
109
+ export async function initRegistry(sessionFile: string | undefined): Promise<void> {
110
+ if (!sessionFile) {
111
+ currentKey = `__ephemeral__-${Date.now()}-${Math.random().toString(36).slice(2)}`;
112
+ currentSidecar = undefined;
113
+ registries.set(currentKey, newSessionState());
114
+ return;
115
+ }
116
+ const key = sidecarKeyFor(sessionFile);
117
+ currentKey = key;
118
+ currentSidecar = sidecarPath(key);
119
+ let events: RegistryEvent[] = [];
120
+ try {
121
+ const raw = await readFile(currentSidecar, "utf-8");
122
+ events = parseRegistryLog(raw);
123
+ } catch (error) {
124
+ if (errCode(error) !== "ENOENT") {
125
+ console.error("Failed to read anchor registry sidecar:", error);
126
+ }
127
+ }
128
+ const folded = foldRegistryEvents(events);
129
+ seedServedFromOwned(folded);
130
+ registries.set(key, folded);
131
+ try {
132
+ await mkdir(sessionClaimsDir(), { recursive: true });
133
+ await appendFile(currentSidecar, JSON.stringify({ kind: "session", sessionFile } satisfies RegistryEvent) + "\n", "utf-8");
134
+ } catch (error) {
135
+ console.error("Failed to initialize anchor registry sidecar:", error);
136
+ }
137
+ }
138
+
139
+ function current(): SessionState | undefined {
140
+ if (!currentKey) return undefined;
141
+ return registries.get(currentKey);
142
+ }
143
+
144
+ function appendEvent(event: RegistryEvent): void {
145
+ appendFile(currentSidecar!, JSON.stringify(event) + "\n", "utf-8").catch((error) => {
146
+ console.error("Failed to append registry event:", error);
147
+ });
148
+ }
149
+
150
+ function fingerprintIndex(state: SessionState, path: string): Map<string, string[]> {
151
+ const index = new Map<string, string[]>();
152
+ for (const [anchor, entry] of state.owned) {
153
+ if (entry.path !== path) continue;
154
+ const list = index.get(entry.checksum) ?? [];
155
+ list.push(anchor);
156
+ index.set(entry.checksum, list);
157
+ }
158
+ return index;
159
+ }
160
+
161
+ const MINT_PROBE_LIMIT = 8192;
162
+
163
+ export function mintAnchor(state: SessionState): string {
164
+ for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
165
+ state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
166
+ const candidate = anchorAt(state.probe);
167
+ if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
168
+ return candidate;
169
+ }
170
+ }
171
+ for (let step = 1; step <= ANCHOR_COUNT; step++) {
172
+ const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
173
+ if (!state.owned.has(candidate)) {
174
+ for (const served of state.served.values()) served.delete(candidate);
175
+ return candidate;
176
+ }
177
+ }
178
+ throw new Error(
179
+ `${ANCHOR_POOL_EXHAUSTED_PREFIX}; free anchors with /clear-anchors or use write for very large files.`,
180
+ );
181
+ }
182
+
183
+ export function allocateAnchor(path: string, checksum: string): string {
184
+ const state = current();
185
+ if (!state) {
186
+ throw new Error("[E_REGISTRY] The anchor registry is not initialized; call initRegistry on session_start first.");
187
+ }
188
+ const anchor = mintAnchor(state);
189
+ state.everMinted.add(anchor);
190
+ state.owned.set(anchor, { path, checksum });
191
+ if (currentSidecar) {
192
+ appendEvent({ kind: "allocate", path, rows: [[anchor, checksum]] });
193
+ }
194
+ return anchor;
195
+ }
196
+
197
+
198
+
199
+ export function freeAnchors(path: string, anchors?: string[]): void {
200
+ const state = current();
201
+ if (!state) return;
202
+ const freed: string[] = [];
203
+ if (anchors) {
204
+ for (const anchor of anchors) {
205
+ if (state.owned.has(anchor)) {
206
+ freed.push(anchor);
207
+ state.owned.delete(anchor);
208
+ state.served.get(path)?.delete(anchor);
209
+ }
210
+ }
211
+ } else {
212
+ for (const [anchor, entry] of [...state.owned]) {
213
+ if (entry.path === path) {
214
+ freed.push(anchor);
215
+ state.owned.delete(anchor);
216
+ }
217
+ }
218
+ state.served.delete(path);
219
+ }
220
+ if (freed.length > 0 && currentSidecar) {
221
+ appendEvent({ kind: "free", path, anchors: anchors ?? undefined });
222
+ }
223
+ }
224
+
225
+ export function clearRegistry(): void {
226
+ const state = current();
227
+ if (!state) return;
228
+ state.owned.clear();
229
+ state.served.clear();
230
+ if (!currentSidecar) return;
231
+ appendEvent({ kind: "clear" });
232
+ }
233
+
234
+ export function servedForPath(path: string): Map<string, string> | undefined {
235
+ return current()?.served.get(path);
236
+ }
237
+
238
+ export function markServed(
239
+ path: string,
240
+ entries: Array<[string, string]>,
241
+ scope?: ReadonlySet<string>,
242
+ ): void {
243
+ const state = current();
244
+ if (!state) return;
245
+ if (!state.served.has(path) && entries.length === 0 && !scope) return;
246
+ let served = state.served.get(path);
247
+ if (!served) {
248
+ served = new Map();
249
+ state.served.set(path, served);
250
+ }
251
+ if (scope) {
252
+ for (const anchor of [...served.keys()]) {
253
+ if (!scope.has(anchor)) served.delete(anchor);
254
+ }
255
+ }
256
+ for (const [anchor, checksum] of entries) {
257
+ served.set(anchor, checksum);
258
+ }
259
+ }
260
+
261
+ export function ownerOf(anchor: string): OwnedAnchor | undefined {
262
+ return current()?.owned.get(anchor);
263
+ }
264
+
265
+ export function ownersForPath(path: string): Map<string, string> {
266
+ const claims = new Map<string, string>();
267
+ const state = current();
268
+ if (!state) return claims;
269
+ for (const [anchor, entry] of state.owned) {
270
+ if (entry.path === path) claims.set(anchor, entry.checksum);
271
+ }
272
+ return claims;
273
+ }
274
+
275
+ export function ensureRegistry(): void {
276
+ if (currentKey && registries.has(currentKey)) return;
277
+ initRegistry(undefined).catch(() => undefined);
278
+ }
279
+
280
+ function cloneState(state: SessionState): SessionState {
281
+ return {
282
+ owned: new Map(state.owned),
283
+ served: new Map([...state.served].map(([path, served]) => [path, new Map(served)])),
284
+ everMinted: new Set(state.everMinted),
285
+ probe: state.probe,
286
+ };
287
+ }
288
+
289
+ export interface Aligned {
290
+ anchors: string[];
291
+ freed: string[];
292
+ minted: string[];
293
+ }
294
+
295
+ interface MintedAt {
296
+ index: number;
297
+ anchor: string;
298
+ checksum: string;
299
+ }
300
+
301
+ function computeParts(
302
+ prevChecksums: string[] | undefined,
303
+ newChecksums: string[],
304
+ ): Diff.ArrayChange<string>[] {
305
+ if (!prevChecksums) {
306
+ return [{ count: newChecksums.length, added: true, removed: false, value: [] } as unknown as Diff.ArrayChange<string>];
307
+ }
308
+ const min = Math.min(prevChecksums.length, newChecksums.length);
309
+ let prefix = 0;
310
+ while (prefix < min && prevChecksums[prefix] === newChecksums[prefix]) prefix++;
311
+ let suffix = 0;
312
+ while (
313
+ suffix < min - prefix &&
314
+ prevChecksums[prevChecksums.length - 1 - suffix] === newChecksums[newChecksums.length - 1 - suffix]
315
+ ) {
316
+ suffix++;
317
+ }
318
+ if (prefix + suffix >= min && prevChecksums.length === newChecksums.length) {
319
+ return [{ count: newChecksums.length, value: [], added: false, removed: false } as unknown as unknown as Diff.ArrayChange<string>];
320
+ }
321
+ const prevMid = prevChecksums.slice(prefix, prevChecksums.length - suffix);
322
+ const newMid = newChecksums.slice(prefix, newChecksums.length - suffix);
323
+ if (prevMid.length === 0) {
324
+ return [
325
+ { count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
326
+ { count: newMid.length, added: true, removed: false, value: [] } as unknown as Diff.ArrayChange<string>,
327
+ { count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
328
+ ];
329
+ }
330
+ if (newMid.length === 0) {
331
+ return [
332
+ { count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
333
+ { count: prevMid.length, removed: true, added: false, value: [] } as unknown as Diff.ArrayChange<string>,
334
+ { count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
335
+ ];
336
+ }
337
+ if (prevMid.length * newMid.length > 4_000_000) {
338
+ return [
339
+ { count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
340
+ { count: prevMid.length, removed: true, added: false, value: [] } as unknown as Diff.ArrayChange<string>,
341
+ { count: newMid.length, added: true, removed: false, value: [] } as unknown as Diff.ArrayChange<string>,
342
+ { count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>,
343
+ ];
344
+ }
345
+ const midParts = Diff.diffArrays(prevMid, newMid) as unknown as Diff.ArrayChange<string>[];
346
+ const parts: Diff.ArrayChange<string>[] = [];
347
+ if (prefix > 0) parts.push({ count: prefix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>);
348
+ parts.push(...midParts);
349
+ if (suffix > 0) parts.push({ count: suffix, value: [], added: false, removed: false } as unknown as Diff.ArrayChange<string>);
350
+ return parts;
351
+ }
352
+
353
+ export function alignOwnershipWithSpans(
354
+ path: string,
355
+ prevAnchors: string[],
356
+ prevChecksums: string[],
357
+ newChecksums: string[],
358
+ spans: { start: number; end: number; replacementCount: number }[],
359
+ options?: { shadow?: boolean },
360
+ ): Aligned {
361
+ const state = options?.shadow ? cloneState(current()!) : current()!;
362
+ const freed: { anchor: string; checksum: string }[] = [];
363
+ const minted: MintedAt[] = [];
364
+ const log = (event: RegistryEvent): void => {
365
+ if (!options?.shadow && currentSidecar) appendEvent(event);
366
+ };
367
+
368
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
369
+ const anchors = [...prevAnchors];
370
+ let offset = 0;
371
+ for (const span of sorted) {
372
+ const prevStart = span.start;
373
+ const start = span.start + offset;
374
+ const end = span.end + offset;
375
+ const spanLength = end - start + 1;
376
+ const spanFreed: { anchor: string; checksum: string }[] = [];
377
+ for (let i = start; i <= end; i++) {
378
+ const anchor = anchors[i]!;
379
+ if (state.owned.has(anchor)) {
380
+ state.owned.delete(anchor);
381
+ const checksum = prevChecksums[i - offset] ?? "";
382
+ spanFreed.push({ anchor, checksum });
383
+ freed.push(spanFreed[spanFreed.length - 1]!);
384
+ }
385
+ }
386
+ const replacement: (string | undefined)[] = new Array(span.replacementCount);
387
+ for (let k = 0; k < span.replacementCount; k++) {
388
+ const positional = k < spanLength ? prevAnchors[prevStart + k] : undefined;
389
+ if (
390
+ positional !== undefined &&
391
+ prevChecksums[prevStart + k] === newChecksums[start + k] &&
392
+ (!state.owned.has(positional) || state.owned.get(positional)!.path === path)
393
+ ) {
394
+ replacement[k] = positional;
395
+ const freedAt = freed.findIndex((candidate) => candidate.anchor === positional);
396
+ if (freedAt >= 0) freed.splice(freedAt, 1);
397
+ const pooledAt = spanFreed.findIndex((candidate) => candidate.anchor === positional);
398
+ if (pooledAt >= 0) spanFreed.splice(pooledAt, 1);
399
+ }
400
+ }
401
+ for (let k = 0; k < span.replacementCount; k++) {
402
+ if (replacement[k] !== undefined) continue;
403
+ const anchor = mintAnchor(state);
404
+ const checksum = newChecksums[start + k]!;
405
+ minted.push({ index: start + k, anchor, checksum });
406
+ state.owned.set(anchor, { path, checksum });
407
+ replacement[k] = anchor;
408
+ }
409
+ const settled = replacement as string[];
410
+ for (let k = 0; k < span.replacementCount; k++) {
411
+ state.everMinted.add(settled[k]!);
412
+ state.owned.set(settled[k]!, { path, checksum: newChecksums[start + k]! });
413
+ }
414
+ anchors.splice(start, spanLength, ...settled);
415
+ offset += span.replacementCount - spanLength;
416
+ }
417
+ const rows: [string, string][] = minted.map((m) => [m.anchor, m.checksum]);
418
+ if (rows.length > 0) log({ kind: "allocate", path, rows });
419
+ if (freed.length > 0) log({ kind: "free", path, anchors: freed.map((f) => f.anchor) });
420
+ return { anchors, freed: freed.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
421
+ }
422
+
423
+ export function alignOwnership(
424
+ path: string,
425
+ prevAnchors: string[] | undefined,
426
+ prevChecksums: string[] | undefined,
427
+ newChecksums: string[],
428
+ options?: { shadow?: boolean },
429
+ ): Aligned {
430
+ const state = options?.shadow ? cloneState(current()!) : current()!;
431
+ const anchors: string[] = new Array(newChecksums.length);
432
+ const freed: { anchor: string; checksum: string }[] = [];
433
+ const minted: MintedAt[] = [];
434
+ const log = (event: RegistryEvent): void => {
435
+ if (!options?.shadow && currentSidecar) appendEvent(event);
436
+ };
437
+ if (prevAnchors) {
438
+ for (const anchor of prevAnchors) state.everMinted.add(anchor);
439
+ }
440
+
441
+ const parts: Diff.ArrayChange<string>[] = computeParts(prevChecksums, newChecksums);
442
+ let prevIdx = 0;
443
+ let newIdx = 0;
444
+ for (const part of parts) {
445
+ const count = part.count ?? 0;
446
+ if (part.added) {
447
+ for (let k = 0; k < count; k++) {
448
+ const checksum = newChecksums[newIdx + k]!;
449
+ const anchor = mintAnchor(state);
450
+ state.everMinted.add(anchor);
451
+ state.owned.set(anchor, { path, checksum });
452
+ minted.push({ index: newIdx + k, anchor, checksum });
453
+ anchors[newIdx + k] = anchor;
454
+ }
455
+ newIdx += count;
456
+ } else if (part.removed) {
457
+ for (let k = 0; k < count; k++) {
458
+ const anchor = prevAnchors![prevIdx + k]!;
459
+ const entry = state.owned.get(anchor);
460
+ if (entry && entry.path === path) {
461
+ state.owned.delete(anchor);
462
+ freed.push({ anchor, checksum: prevChecksums![prevIdx + k]! });
463
+ }
464
+ }
465
+ prevIdx += count;
466
+ } else {
467
+ for (let k = 0; k < count; k++) {
468
+ const anchor = prevAnchors![prevIdx + k]!;
469
+ const entry = state.owned.get(anchor);
470
+ const checksum = newChecksums[newIdx + k]!;
471
+ if (entry && entry.path !== path) {
472
+ const fresh = mintAnchor(state);
473
+ state.owned.set(fresh, { path, checksum });
474
+ anchors[newIdx + k] = fresh;
475
+ } else {
476
+ state.owned.set(anchor, { path, checksum });
477
+ anchors[newIdx + k] = anchor;
478
+ }
479
+ }
480
+ newIdx += count;
481
+ prevIdx += count;
482
+ }
483
+ }
484
+
485
+ const rows: [string, string][] = [];
486
+ for (let i = 0; i < anchors.length; i++) {
487
+ rows.push([anchors[i]!, newChecksums[i]!]);
488
+ }
489
+ if (minted.length > 0) log({ kind: "allocate", path, rows: rows.filter(([anchor]) => minted.some((m) => m.anchor === anchor)) });
490
+ if (freed.length > 0) log({ kind: "free", path, anchors: freed.map((f) => f.anchor) });
491
+ return { anchors, freed: freed.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
492
+ }
493
+
494
+ export async function allocateFileAnchors(
495
+ store: HashStore,
496
+ path: string,
497
+ content: string,
498
+ options?: {
499
+ persist?: boolean;
500
+ shadow?: boolean;
501
+ previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] };
502
+ },
503
+ ): Promise<string[]> {
504
+ ensureRegistry();
505
+ const shadow = options?.shadow === true;
506
+ const lines = splitLines(content);
507
+ const checksums = lines.map((line) => contentChecksum(hashSource(line)));
508
+ if (options?.previous?.spans) {
509
+ const sessionState = current()!;
510
+ const prevChecksums = splitLines(options.previous.content).map((line) => contentChecksum(hashSource(line)));
511
+ const aligned = alignOwnershipWithSpans(path, options.previous.hashes, prevChecksums, checksums, options.previous.spans, { shadow });
512
+ for (let i = 0; i < aligned.anchors.length; i++) {
513
+ sessionState.owned.set(aligned.anchors[i]!, { path, checksum: checksums[i]! });
514
+ }
515
+ if (!shadow && options.persist !== false) {
516
+ persistSnapshot(store, path, content, aligned.anchors, checksums);
517
+ }
518
+ return aligned.anchors;
519
+ }
520
+ let prevAnchors: string[] | undefined;
521
+ let prevChecksums: string[] | undefined;
522
+ if (options?.previous) {
523
+ prevAnchors = options.previous.hashes;
524
+ prevChecksums = splitLines(options.previous.content).map((line) => contentChecksum(hashSource(line)));
525
+ } else {
526
+ const previousState = getAllocatedState(store, path, !shadow);
527
+ if (previousState) {
528
+ prevAnchors = previousState.anchors;
529
+ prevChecksums = previousState.checksums;
530
+ if (!prevChecksums && previousState.contentChecksum === contentChecksum(content)) {
531
+ prevChecksums = checksums;
532
+ }
533
+ }
534
+ }
535
+ const aligned: Aligned = prevAnchors
536
+ ? alignOwnership(path, prevAnchors, prevChecksums, checksums, { shadow })
537
+ : (() => {
538
+ const state = shadow ? cloneState(current()!) : current()!;
539
+ const reuseIndex = fingerprintIndex(state, path);
540
+ const anchors: string[] = checksums.map((checksum) => {
541
+ const candidates = reuseIndex.get(checksum) ?? [];
542
+ const anchor = candidates.length > 0 ? candidates.shift()! : mintAnchor(state);
543
+ state.everMinted.add(anchor);
544
+ state.owned.set(anchor, { path, checksum });
545
+ return anchor;
546
+ });
547
+ if (!shadow && currentSidecar) {
548
+ appendEvent({ kind: "allocate", path, rows: anchors.map((a, i) => [a, checksums[i]!]) });
549
+ }
550
+ return { anchors, freed: [], minted: anchors };
551
+ })();
552
+ if (!shadow && options?.persist !== false) {
553
+ persistSnapshot(store, path, content, aligned.anchors, checksums);
554
+ }
555
+ return aligned.anchors;
556
+ }
557
+
558
+ export function adoptAnchors(path: string, entries: Map<string, string>): void {
559
+ ensureRegistry();
560
+ const state = current()!;
561
+ let served = state.served.get(path);
562
+ if (!served) {
563
+ served = new Map();
564
+ state.served.set(path, served);
565
+ }
566
+ for (const [anchor, checksum] of entries) {
567
+ state.owned.set(anchor, { path, checksum });
568
+ served.set(anchor, checksum);
569
+ }
570
+ if (currentSidecar && entries.size > 0) {
571
+ appendEvent({ kind: "allocate", path, rows: [...entries] });
572
+ }
573
+ }
574
+
575
+ export function resetRegistryForTests(): void {
576
+ currentKey = undefined;
577
+ currentSidecar = undefined;
578
+ registries.clear();
579
+ }
580
+
581
+ export async function gcRegistrySidecars(): Promise<void> {
582
+ let names: string[];
583
+ try {
584
+ names = await readdir(sessionClaimsDir());
585
+ } catch (error) {
586
+ if (errCode(error) !== "ENOENT") console.error("Failed to list registry sidecars:", error);
587
+ return;
588
+ }
589
+ for (const name of names) {
590
+ if (!name.endsWith(SIDECAR_SUFFIX)) continue;
591
+ const sidecar = join(sessionClaimsDir(), name);
592
+ try {
593
+ const raw = await readFile(sidecar, "utf-8");
594
+ const header = JSON.parse(raw.split("\n")[0] ?? "{}") as { kind?: string; sessionFile?: string };
595
+ if (header.kind !== "session" || !header.sessionFile) continue;
596
+ await stat(header.sessionFile);
597
+ } catch (error) {
598
+ if (errCode(error) === "ENOENT") {
599
+ await rm(sidecar, { force: true });
600
+ } else {
601
+ console.error("Failed to inspect registry sidecar:", error);
602
+ }
603
+ }
604
+ }
605
+ }
package/src/commit.ts CHANGED
@@ -6,11 +6,14 @@ import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-res
6
6
  import { saveUndo } from "./replace-undo";
7
7
  import { safeSnapId } from "./file-reader";
8
8
  import { writeAtomic } from "./fs-write";
9
- import { recordServedSafe, buildServedMap, servedHashesFromDiff } from "./served";
9
+ import { servedHashesFromDiff, buildServedMap } from "./served";
10
+ import { lineHashes } from "./hashline";
11
+ import { hashSpan } from "./replace";
10
12
  import { restoreEndings } from "./normalize";
11
13
  import { splitLines } from "./utils";
12
-
14
+ import { markServed as markServedScoped } from "./anchor-registry";
13
15
  export interface CommitMeta {
16
+ editAnchors?: [string, string];
14
17
  path: string;
15
18
  absolutePath: string;
16
19
  mutationTargetPath: string;
@@ -102,12 +105,22 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
102
105
  removedLines: pipe.totalRemovedLines,
103
106
  };
104
107
 
108
+ const span = meta.editAnchors ? hashSpan(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1]) : undefined;
109
+ const resultCount = splitLines(pipe.result).length;
110
+ const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
111
+ const resultHashes = pipe.result === pipe.originalNormalized
112
+ ? pipe.originalHashes
113
+ : await lineHashes(pipe.result, mutationTargetPath, {
114
+ content: pipe.originalNormalized,
115
+ hashes: pipe.originalHashes,
116
+ spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
117
+ });
105
118
  const successInput = {
106
119
  path,
107
120
  originalNormalized: pipe.originalNormalized,
108
121
  originalHashes: pipe.originalHashes,
109
122
  result: pipe.result,
110
- resultHashes: pipe.resultHashes,
123
+ resultHashes,
111
124
  warnings,
112
125
  snapshotId: updatedSnapshotId,
113
126
  editMeta,
@@ -116,10 +129,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
116
129
  };
117
130
  const changed = buildChanged(successInput, meta.verb);
118
131
  if (changed.details.diff) {
119
- const diffHashes = servedHashesFromDiff(changed.details.diff);
120
- const resultLines = splitLines(pipe.result);
121
- const servedMap = buildServedMap(pipe.resultHashes, resultLines, diffHashes);
122
- await recordServedSafe(mutationTargetPath, servedMap, "post-edit diff", new Set(pipe.resultHashes));
132
+ markServedScoped(
133
+ mutationTargetPath,
134
+ buildServedMap(resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff)),
135
+ new Set(resultHashes),
136
+ );
123
137
  }
124
138
  return changed;
125
139
  }
package/src/constants.ts CHANGED
@@ -9,8 +9,11 @@ export const MAX_GREP_LINE_BYTES = 500;
9
9
  export const MAX_DIFF_INPUT_BYTES = 1024 * 1024;
10
10
 
11
11
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
12
- export const HASH_STORE_VERSION = 7;
12
+ export const HASH_STORE_VERSION = 8;
13
13
  export const NEW_CONTENT_NOT_ARRAY_MSG =
14
14
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
15
15
 
16
+ export const ANCHOR_POOL_EXHAUSTED_PREFIX =
17
+ "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
18
+
16
19
  export const DEDUP_ANCHOR = "dedup";