pi-hashline-edit-pro 4.2.7 → 4.2.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.7",
3
+ "version": "4.2.9",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -152,13 +152,14 @@ interface SessionState {
152
152
  served: Map<string, Map<string, string>>;
153
153
  everMinted: AnchorMintedSet;
154
154
  probe: number;
155
+ allocatedChecksum: Map<string, string>;
155
156
  }
156
157
 
157
158
  const SIDECAR_SUFFIX = ".registry.jsonl";
158
159
  const SIDECAR_COMPACT_LINES = 5000;
159
160
  const SIDECAR_COMPACT_BYTES = 1024 * 1024;
160
- const SIDECAR_COMPACT_CHUNK = 5000;
161
- const SIDECAR_HEADER_BYTES = 64 * 1024;
161
+ export const SIDECAR_COMPACT_LINE_BYTES = 48 * 1024;
162
+ export const SIDECAR_HEADER_BYTES = 64 * 1024;
162
163
  const SIDECAR_HEADER_CHUNK = 4096;
163
164
  let currentKey: string | undefined;
164
165
  const sidecarByKey = new Map<string, string>();
@@ -174,7 +175,7 @@ function newSessionState(seed?: string): SessionState {
174
175
  probe = (probe * 256 + byte) % ANCHOR_COUNT;
175
176
  }
176
177
  }
177
- return { owned: new Map(), served: new Map(), everMinted: new Set(), probe };
178
+ return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map() };
178
179
  }
179
180
 
180
181
  function seedServedFromOwned(state: SessionState): void {
@@ -246,6 +247,45 @@ export function shouldCompactSidecar(raw: string): boolean {
246
247
  for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) lines += 1;
247
248
  return lines >= SIDECAR_COMPACT_LINES;
248
249
  }
250
+
251
+ function parseSessionFileLine(line: string): string | undefined {
252
+ const trimmed = line.trim();
253
+ if (trimmed.length === 0) return undefined;
254
+ try {
255
+ const event = JSON.parse(trimmed) as { kind?: unknown; sessionFile?: unknown };
256
+ if (event.kind === "session" && typeof event.sessionFile === "string" && event.sessionFile.length > 0) return event.sessionFile;
257
+ } catch {
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ function firstLineSessionFile(raw: string): string | undefined {
263
+ const newline = raw.indexOf("\n");
264
+ return parseSessionFileLine(newline >= 0 ? raw.slice(0, newline) : raw);
265
+ }
266
+
267
+ function chunkBySerializedBytes<T>(items: T[], wrap: (chunk: T[]) => unknown, maxBytes: number): T[][] {
268
+ const chunks: T[][] = [];
269
+ if (items.length === 0) return chunks;
270
+ const overhead = Buffer.byteLength(JSON.stringify(wrap([])), "utf-8");
271
+ let chunk: T[] = [];
272
+ let bytes = overhead;
273
+ for (const item of items) {
274
+ const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf-8");
275
+ const nextBytes = bytes + itemBytes + (chunk.length > 0 ? 1 : 0);
276
+ if (chunk.length > 0 && nextBytes > maxBytes) {
277
+ chunks.push(chunk);
278
+ chunk = [item];
279
+ bytes = overhead + itemBytes;
280
+ } else {
281
+ chunk.push(item);
282
+ bytes = nextBytes;
283
+ }
284
+ }
285
+ chunks.push(chunk);
286
+ return chunks;
287
+ }
288
+
249
289
  export function buildCompactedLog(sessionFile: string, state: SessionState): string {
250
290
  const byPath = new Map<string, Array<[string, string]>>();
251
291
  for (const [anchor, entry] of state.owned) {
@@ -255,18 +295,18 @@ export function buildCompactedLog(sessionFile: string, state: SessionState): str
255
295
  }
256
296
  const out: string[] = [JSON.stringify({ kind: "session", sessionFile })];
257
297
  for (const [path, rows] of byPath) {
258
- for (let i = 0; i < rows.length; i += SIDECAR_COMPACT_CHUNK) {
259
- out.push(JSON.stringify({ kind: "allocate", path, rows: rows.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
298
+ for (const chunk of chunkBySerializedBytes(rows, (part) => ({ kind: "allocate", path, rows: part }), SIDECAR_COMPACT_LINE_BYTES)) {
299
+ out.push(JSON.stringify({ kind: "allocate", path, rows: chunk }));
260
300
  }
261
301
  }
262
302
  const freedHistory = [...state.everMinted].filter((anchor) => !state.owned.has(anchor));
263
- for (let i = 0; i < freedHistory.length; i += SIDECAR_COMPACT_CHUNK) {
264
- out.push(JSON.stringify({ kind: "minted", anchors: freedHistory.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
303
+ for (const chunk of chunkBySerializedBytes(freedHistory, (part) => ({ kind: "minted", anchors: part }), SIDECAR_COMPACT_LINE_BYTES)) {
304
+ out.push(JSON.stringify({ kind: "minted", anchors: chunk }));
265
305
  }
266
306
  return out.join("\n") + "\n";
267
307
  }
268
- async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState): Promise<void> {
269
- if (!shouldCompactSidecar(raw)) return;
308
+ async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState, force = false): Promise<void> {
309
+ if (!force && !shouldCompactSidecar(raw)) return;
270
310
  const tmp = `${sidecar}.compact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
271
311
  try {
272
312
  const compacted = buildCompactedLog(sessionFile, state);
@@ -299,7 +339,7 @@ async function loadRegistryState(key: string, sessionFile: string, token: object
299
339
  registries.set(key, folded);
300
340
  sidecarByKey.set(key, sidecar);
301
341
  if (rawLog.length > 0) {
302
- await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded);
342
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, firstLineSessionFile(rawLog) !== sessionFile);
303
343
  }
304
344
  try {
305
345
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
@@ -476,6 +516,7 @@ export function clearRegistry(): void {
476
516
  if (!state) return;
477
517
  state.owned.clear();
478
518
  state.served.clear();
519
+ state.allocatedChecksum.clear();
479
520
  appendEvent({ kind: "clear" });
480
521
  }
481
522
 
@@ -532,6 +573,7 @@ export function shadowStateFrom(state: SessionState): SessionState {
532
573
  served: new Map(),
533
574
  everMinted: new ShadowMintedSet(state.everMinted),
534
575
  probe: state.probe,
576
+ allocatedChecksum: state.allocatedChecksum,
535
577
  };
536
578
  }
537
579
 
@@ -743,6 +785,11 @@ export function alignOwnership(
743
785
  return { anchors, freed: freed.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
744
786
  }
745
787
 
788
+ function snapshotMatchesAllocation(state: SessionState | undefined, path: string, checksum: string): boolean {
789
+ const allocated = state?.allocatedChecksum.get(path);
790
+ return allocated === undefined || allocated === checksum;
791
+ }
792
+
746
793
  export async function allocateFileAnchors(
747
794
  store: HashStore,
748
795
  path: string,
@@ -754,12 +801,14 @@ export async function allocateFileAnchors(
754
801
  },
755
802
  ): Promise<string[]> {
756
803
  ensureRegistry();
804
+ const registry = current();
757
805
  const shadow = options?.shadow === true;
758
806
  const lines = splitLines(content);
759
807
  const checksums = lines.map((line) => contentChecksum(hashSource(line)));
760
808
  if (options?.previous?.spans) {
761
809
  const prevChecksums = splitLines(options.previous.content).map((line) => contentChecksum(hashSource(line)));
762
810
  const aligned = alignOwnershipWithSpans(path, options.previous.hashes, prevChecksums, checksums, options.previous.spans, { shadow });
811
+ if (!shadow && registry) registry.allocatedChecksum.set(path, contentChecksum(content));
763
812
  if (!shadow && options.persist !== false) {
764
813
  persistSnapshot(store, path, content, aligned.anchors, checksums);
765
814
  }
@@ -772,7 +821,7 @@ export async function allocateFileAnchors(
772
821
  prevChecksums = splitLines(options.previous.content).map((line) => contentChecksum(hashSource(line)));
773
822
  } else {
774
823
  const previousState = getAllocatedState(store, path, !shadow);
775
- if (previousState) {
824
+ if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
776
825
  prevAnchors = previousState.anchors;
777
826
  prevChecksums = previousState.checksums;
778
827
  if (!prevChecksums && previousState.contentChecksum === contentChecksum(content)) {
@@ -800,6 +849,7 @@ export async function allocateFileAnchors(
800
849
  }
801
850
  return { anchors, freed: [], minted: anchors };
802
851
  })();
852
+ if (!shadow && registry) registry.allocatedChecksum.set(path, contentChecksum(content));
803
853
  if (!shadow && options?.persist !== false) {
804
854
  persistSnapshot(store, path, content, aligned.anchors, checksums);
805
855
  }
@@ -835,7 +885,7 @@ export function resetRegistryForTests(): void {
835
885
  registries.clear();
836
886
  }
837
887
 
838
- export async function readSidecarHeader(sidecar: string): Promise<string> {
888
+ async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
839
889
  const handle = await open(sidecar, "r");
840
890
  const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
841
891
  try {
@@ -844,16 +894,31 @@ export async function readSidecarHeader(sidecar: string): Promise<string> {
844
894
  const { bytesRead } = await handle.read(buffer, readBytes, Math.min(SIDECAR_HEADER_CHUNK, buffer.length - readBytes), readBytes);
845
895
  if (bytesRead === 0) break;
846
896
  readBytes += bytesRead;
847
- const text = buffer.subarray(0, readBytes).toString("utf-8");
848
- const newline = text.indexOf("\n");
849
- if (newline >= 0) return text.slice(0, newline);
850
897
  }
851
- return buffer.subarray(0, readBytes).toString("utf-8");
898
+ return { text: buffer.subarray(0, readBytes).toString("utf-8"), filled: readBytes === buffer.length };
852
899
  } finally {
853
900
  await handle.close();
854
901
  }
855
902
  }
856
903
 
904
+ export async function readSidecarHeader(sidecar: string): Promise<string> {
905
+ const { text, filled } = await readSidecarWindow(sidecar);
906
+ const newline = text.indexOf("\n");
907
+ if (newline >= 0) return text.slice(0, newline);
908
+ return filled ? "" : text;
909
+ }
910
+
911
+ export async function readSidecarSessionFile(sidecar: string): Promise<string | undefined> {
912
+ const { text } = await readSidecarWindow(sidecar);
913
+ const lastNewline = text.lastIndexOf("\n");
914
+ const complete = lastNewline >= 0 ? text.slice(0, lastNewline).split("\n") : [];
915
+ for (const line of complete) {
916
+ const sessionFile = parseSessionFileLine(line);
917
+ if (sessionFile !== undefined) return sessionFile;
918
+ }
919
+ return parseSessionFileLine(lastNewline >= 0 ? text.slice(lastNewline + 1) : text);
920
+ }
921
+
857
922
  export function releaseRegistrySession(key: string): void {
858
923
  loadTokens.delete(key);
859
924
  if (currentKey === key) currentKey = undefined;
@@ -884,9 +949,9 @@ export async function gcRegistrySidecars(): Promise<void> {
884
949
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
885
950
  const sidecar = join(sessionClaimsDir(), name);
886
951
  try {
887
- const header = JSON.parse(await readSidecarHeader(sidecar) || "{}") as { kind?: string; sessionFile?: string };
888
- if (header.kind !== "session" || !header.sessionFile) continue;
889
- await stat(header.sessionFile);
952
+ const sessionFile = await readSidecarSessionFile(sidecar);
953
+ if (sessionFile === undefined) continue;
954
+ await stat(sessionFile);
890
955
  } catch (error) {
891
956
  if (errCode(error) === "ENOENT") {
892
957
  await rm(sidecar, { force: true });
package/src/fs-write.ts CHANGED
@@ -152,6 +152,7 @@ export async function writeAtomic(
152
152
  path: string,
153
153
  content: string,
154
154
  expectedIdentity?: FileIdentity,
155
+ restoreMode?: number,
155
156
  ): Promise<void> {
156
157
  const targetPath = await resolveTarget(path);
157
158
 
@@ -193,8 +194,9 @@ export async function writeAtomic(
193
194
  const tempHandle = await open(tempPath, "wx", 0o600);
194
195
  try {
195
196
  await tempHandle.writeFile(content, "utf-8");
196
- if (existingStats) {
197
- await tempHandle.chmod(existingStats.mode & 0o7777);
197
+ const mode = existingStats ? existingStats.mode & 0o7777 : restoreMode;
198
+ if (mode !== undefined) {
199
+ await tempHandle.chmod(mode);
198
200
  }
199
201
  await tempHandle.sync();
200
202
  } catch (error: unknown) {
@@ -37,7 +37,7 @@ export async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
37
37
  throw lastError;
38
38
  }
39
39
 
40
- export function retriedWrite(stmt: { run(...params: (string | number)[]): unknown }): (...params: (string | number)[]) => void {
40
+ export function retriedWrite(stmt: { run(...params: (string | number | null)[]): unknown }): (...params: (string | number | null)[]) => void {
41
41
  return (...params) => {
42
42
  withBusyRetry(() => { stmt.run(...params); });
43
43
  };
package/src/hash-store.ts CHANGED
@@ -26,7 +26,7 @@ export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parse
26
26
  export { SNAPSHOT_CACHE_LIMIT };
27
27
  export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
28
28
 
29
- type SqlParams = (string | number)[];
29
+ type SqlParams = (string | number | null)[];
30
30
 
31
31
  interface RawStatement {
32
32
  get(...params: SqlParams): unknown;
@@ -140,6 +140,7 @@ export interface UndoRecord {
140
140
  ending: string;
141
141
  hashes: string[];
142
142
  resultContent: string;
143
+ mode?: number;
143
144
  }
144
145
 
145
146
  let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
@@ -190,6 +191,9 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
190
191
  try {
191
192
  db.exec("ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
192
193
  } catch {}
194
+ try {
195
+ db.exec("ALTER TABLE undo ADD COLUMN mode INTEGER");
196
+ } catch {}
193
197
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
194
198
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
195
199
  db.exec("DELETE FROM snapshots");
@@ -209,11 +213,11 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
209
213
  "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, line_checksums = excluded.line_checksums, updated_at = excluded.updated_at"
210
214
  );
211
215
  const undoUpsertStmt = db.prepare(
212
- "INSERT INTO undo (path, content, bom, ending, hashes, result_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
213
- "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, updated_at = excluded.updated_at"
216
+ "INSERT INTO undo (path, content, bom, ending, hashes, result_content, mode, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) " +
217
+ "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, mode = excluded.mode, updated_at = excluded.updated_at"
214
218
  );
215
219
  const undoGetStmt = db.prepare(
216
- "SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
220
+ "SELECT content, bom, ending, hashes, result_content, mode FROM undo WHERE path = ?"
217
221
  );
218
222
  const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
219
223
  const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
@@ -533,6 +537,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
533
537
  entry.ending,
534
538
  JSON.stringify(entry.hashes),
535
539
  entry.resultContent,
540
+ typeof entry.mode === "number" ? entry.mode : null,
536
541
  Date.now(),
537
542
  );
538
543
  touchSession(path);
@@ -549,6 +554,7 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
549
554
  ending: row.ending as string,
550
555
  hashes: parsed,
551
556
  resultContent: row.result_content as string,
557
+ ...(typeof row.mode === "number" ? { mode: row.mode } : {}),
552
558
  };
553
559
  }
554
560
 
@@ -1,5 +1,5 @@
1
1
  import { constants } from "node:fs";
2
- import { open } from "node:fs/promises";
2
+ import { open, stat } from "node:fs/promises";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
@@ -24,6 +24,7 @@ export interface UndoEntry {
24
24
  originalEnding: LineEnding;
25
25
  hashes: string[];
26
26
  resultContent: string;
27
+ mode?: number;
27
28
  }
28
29
 
29
30
  export async function saveUndo(
@@ -34,12 +35,18 @@ export async function saveUndo(
34
35
  try {
35
36
  const store = await loadHashStore();
36
37
  previous = getUndoEntry(store, path);
38
+ let mode: number | undefined;
39
+ try {
40
+ mode = (await stat(path)).mode & 0o7777;
41
+ } catch {
42
+ }
37
43
  upsertUndo(store, path, {
38
44
  content: entry.content,
39
45
  bom: entry.bom,
40
46
  ending: entry.originalEnding,
41
47
  hashes: entry.hashes,
42
48
  resultContent: entry.resultContent,
49
+ ...(mode !== undefined ? { mode } : {}),
43
50
  });
44
51
  } catch (error) {
45
52
  console.error("Failed to persist undo entry:", error);
@@ -75,6 +82,7 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
75
82
  originalEnding,
76
83
  hashes: record.hashes,
77
84
  resultContent: record.resultContent,
85
+ ...(record.mode !== undefined ? { mode: record.mode } : {}),
78
86
  };
79
87
  } catch (error) {
80
88
  console.error("Failed to load undo entry:", error);
@@ -91,6 +99,10 @@ export async function clearUndo(path: string): Promise<void> {
91
99
  }
92
100
  }
93
101
 
102
+ function fallbackFileMode(): number {
103
+ return 0o666 & ~process.umask();
104
+ }
105
+
94
106
  export function regUndo(pi: ExtensionAPI): void {
95
107
  pi.registerTool({
96
108
  name: "undo_last_change",
@@ -172,6 +184,7 @@ export function regUndo(pi: ExtensionAPI): void {
172
184
  mutationTargetPath,
173
185
  undo.bom + restoreEndings(undo.content, undo.originalEnding),
174
186
  currentIdentity,
187
+ undo.mode ?? fallbackFileMode(),
175
188
  );
176
189
 
177
190
  const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);