pi-hashline-edit-pro 3.0.4 → 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.
- package/README.md +111 -129
- package/index.ts +22 -11
- package/package.json +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +1 -2
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +0 -1
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/anchor-registry.ts +605 -0
- package/src/commit.ts +21 -7
- package/src/constants.ts +4 -1
- package/src/edit-common.ts +45 -8
- package/src/file-reader.ts +4 -1
- package/src/grep.ts +26 -8
- package/src/hash-store/cache.ts +0 -4
- package/src/hash-store.ts +50 -137
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/apply.ts +4 -1
- package/src/hashline/hash.ts +12 -175
- package/src/hashline/parse.ts +1 -1
- package/src/hashline/resolve.ts +11 -43
- package/src/insert.ts +16 -31
- package/src/paths.ts +4 -0
- package/src/payload-contract.ts +3 -13
- package/src/read.ts +4 -5
- package/src/replace-render.ts +11 -2
- package/src/replace-undo.ts +15 -5
- package/src/replace.ts +26 -42
- package/src/served.ts +14 -121
- package/src/write-hook.ts +2 -4
- package/src/missing-path.ts +0 -54
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 =
|
|
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";
|