pi-hashline-edit-pro 4.2.8 → 4.2.10

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 CHANGED
@@ -199,7 +199,7 @@ The table is curated for tokenizers, not for humans. Every anchor is the concate
199
199
 
200
200
  Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
201
201
 
202
- Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup, and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
202
+ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process), and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
203
203
 
204
204
  When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
205
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.8",
3
+ "version": "4.2.10",
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",
@@ -1,5 +1,5 @@
1
1
  import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
- import { appendFileSync, chmodSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -158,11 +158,12 @@ interface SessionState {
158
158
  const SIDECAR_SUFFIX = ".registry.jsonl";
159
159
  const SIDECAR_COMPACT_LINES = 5000;
160
160
  const SIDECAR_COMPACT_BYTES = 1024 * 1024;
161
- const SIDECAR_COMPACT_CHUNK = 5000;
161
+ export const SIDECAR_COMPACT_LINE_BYTES = 48 * 1024;
162
162
  export const SIDECAR_HEADER_BYTES = 64 * 1024;
163
163
  const SIDECAR_HEADER_CHUNK = 4096;
164
164
  let currentKey: string | undefined;
165
165
  const sidecarByKey = new Map<string, string>();
166
+ const sessionFileByKey = new Map<string, string>();
166
167
  const pendingInits = new Map<string, Promise<void>>();
167
168
  const loadTokens = new Map<string, object>();
168
169
  const registries = new Map<string, SessionState>();
@@ -247,6 +248,45 @@ export function shouldCompactSidecar(raw: string): boolean {
247
248
  for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) lines += 1;
248
249
  return lines >= SIDECAR_COMPACT_LINES;
249
250
  }
251
+
252
+ function parseSessionFileLine(line: string): string | undefined {
253
+ const trimmed = line.trim();
254
+ if (trimmed.length === 0) return undefined;
255
+ try {
256
+ const event = JSON.parse(trimmed) as { kind?: unknown; sessionFile?: unknown };
257
+ if (event.kind === "session" && typeof event.sessionFile === "string" && event.sessionFile.length > 0) return event.sessionFile;
258
+ } catch {
259
+ }
260
+ return undefined;
261
+ }
262
+
263
+ function firstLineSessionFile(raw: string): string | undefined {
264
+ const newline = raw.indexOf("\n");
265
+ return parseSessionFileLine(newline >= 0 ? raw.slice(0, newline) : raw);
266
+ }
267
+
268
+ function chunkBySerializedBytes<T>(items: T[], wrap: (chunk: T[]) => unknown, maxBytes: number): T[][] {
269
+ const chunks: T[][] = [];
270
+ if (items.length === 0) return chunks;
271
+ const overhead = Buffer.byteLength(JSON.stringify(wrap([])), "utf-8");
272
+ let chunk: T[] = [];
273
+ let bytes = overhead;
274
+ for (const item of items) {
275
+ const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf-8");
276
+ const nextBytes = bytes + itemBytes + (chunk.length > 0 ? 1 : 0);
277
+ if (chunk.length > 0 && nextBytes > maxBytes) {
278
+ chunks.push(chunk);
279
+ chunk = [item];
280
+ bytes = overhead + itemBytes;
281
+ } else {
282
+ chunk.push(item);
283
+ bytes = nextBytes;
284
+ }
285
+ }
286
+ chunks.push(chunk);
287
+ return chunks;
288
+ }
289
+
250
290
  export function buildCompactedLog(sessionFile: string, state: SessionState): string {
251
291
  const byPath = new Map<string, Array<[string, string]>>();
252
292
  for (const [anchor, entry] of state.owned) {
@@ -256,18 +296,18 @@ export function buildCompactedLog(sessionFile: string, state: SessionState): str
256
296
  }
257
297
  const out: string[] = [JSON.stringify({ kind: "session", sessionFile })];
258
298
  for (const [path, rows] of byPath) {
259
- for (let i = 0; i < rows.length; i += SIDECAR_COMPACT_CHUNK) {
260
- out.push(JSON.stringify({ kind: "allocate", path, rows: rows.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
299
+ for (const chunk of chunkBySerializedBytes(rows, (part) => ({ kind: "allocate", path, rows: part }), SIDECAR_COMPACT_LINE_BYTES)) {
300
+ out.push(JSON.stringify({ kind: "allocate", path, rows: chunk }));
261
301
  }
262
302
  }
263
303
  const freedHistory = [...state.everMinted].filter((anchor) => !state.owned.has(anchor));
264
- for (let i = 0; i < freedHistory.length; i += SIDECAR_COMPACT_CHUNK) {
265
- out.push(JSON.stringify({ kind: "minted", anchors: freedHistory.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
304
+ for (const chunk of chunkBySerializedBytes(freedHistory, (part) => ({ kind: "minted", anchors: part }), SIDECAR_COMPACT_LINE_BYTES)) {
305
+ out.push(JSON.stringify({ kind: "minted", anchors: chunk }));
266
306
  }
267
307
  return out.join("\n") + "\n";
268
308
  }
269
- async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState): Promise<void> {
270
- if (!shouldCompactSidecar(raw)) return;
309
+ async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState, force = false): Promise<void> {
310
+ if (!force && !shouldCompactSidecar(raw)) return;
271
311
  const tmp = `${sidecar}.compact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
272
312
  try {
273
313
  const compacted = buildCompactedLog(sessionFile, state);
@@ -300,7 +340,7 @@ async function loadRegistryState(key: string, sessionFile: string, token: object
300
340
  registries.set(key, folded);
301
341
  sidecarByKey.set(key, sidecar);
302
342
  if (rawLog.length > 0) {
303
- await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded);
343
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, firstLineSessionFile(rawLog) !== sessionFile);
304
344
  }
305
345
  try {
306
346
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
@@ -329,6 +369,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
329
369
  registries.set(key, newSessionState(key));
330
370
  return;
331
371
  }
372
+ sessionFileByKey.set(key, sessionFile);
332
373
  await loadRegistryState(key, sessionFile, token);
333
374
  })();
334
375
  pendingInits.set(key, promise);
@@ -384,12 +425,27 @@ function current(): SessionState | undefined {
384
425
  return registries.get(key);
385
426
  }
386
427
 
428
+ function sidecarNeedsSessionRecord(sidecar: string): boolean {
429
+ try {
430
+ return statSync(sidecar).size === 0;
431
+ } catch {
432
+ return true;
433
+ }
434
+ }
435
+
436
+ function ensureSidecarSessionRecord(key: string, sidecar: string): void {
437
+ const sessionFile = sessionFileByKey.get(key);
438
+ if (sessionFile === undefined || !sidecarNeedsSessionRecord(sidecar)) return;
439
+ appendEvent({ kind: "session", sessionFile } satisfies RegistryEvent);
440
+ }
441
+
387
442
  function appendEvent(event: RegistryEvent): void {
388
443
  const key = activeKey();
389
444
  if (!key) return;
390
445
  const sidecar = sidecarByKey.get(key);
391
446
  if (!sidecar) return;
392
447
  try {
448
+ if (event.kind !== "session") ensureSidecarSessionRecord(key, sidecar);
393
449
  appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
394
450
  if (process.platform !== "win32") {
395
451
  try { chmodSync(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
@@ -841,12 +897,13 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
841
897
  export function resetRegistryForTests(): void {
842
898
  currentKey = undefined;
843
899
  sidecarByKey.clear();
900
+ sessionFileByKey.clear();
844
901
  pendingInits.clear();
845
902
  loadTokens.clear();
846
903
  registries.clear();
847
904
  }
848
905
 
849
- export async function readSidecarHeader(sidecar: string): Promise<string> {
906
+ async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
850
907
  const handle = await open(sidecar, "r");
851
908
  const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
852
909
  try {
@@ -855,24 +912,47 @@ export async function readSidecarHeader(sidecar: string): Promise<string> {
855
912
  const { bytesRead } = await handle.read(buffer, readBytes, Math.min(SIDECAR_HEADER_CHUNK, buffer.length - readBytes), readBytes);
856
913
  if (bytesRead === 0) break;
857
914
  readBytes += bytesRead;
858
- const text = buffer.subarray(0, readBytes).toString("utf-8");
859
- const newline = text.indexOf("\n");
860
- if (newline >= 0) return text.slice(0, newline);
861
915
  }
862
- return readBytes === buffer.length ? "" : buffer.subarray(0, readBytes).toString("utf-8");
916
+ return { text: buffer.subarray(0, readBytes).toString("utf-8"), filled: readBytes === buffer.length };
863
917
  } finally {
864
918
  await handle.close();
865
919
  }
866
920
  }
867
921
 
922
+ export async function readSidecarHeader(sidecar: string): Promise<string> {
923
+ const { text, filled } = await readSidecarWindow(sidecar);
924
+ const newline = text.indexOf("\n");
925
+ if (newline >= 0) return text.slice(0, newline);
926
+ return filled ? "" : text;
927
+ }
928
+
929
+ export async function readSidecarSessionFile(sidecar: string): Promise<string | undefined> {
930
+ const { text } = await readSidecarWindow(sidecar);
931
+ const lastNewline = text.lastIndexOf("\n");
932
+ const complete = lastNewline >= 0 ? text.slice(0, lastNewline).split("\n") : [];
933
+ for (const line of complete) {
934
+ const sessionFile = parseSessionFileLine(line);
935
+ if (sessionFile !== undefined) return sessionFile;
936
+ }
937
+ return parseSessionFileLine(lastNewline >= 0 ? text.slice(lastNewline + 1) : text);
938
+ }
939
+
868
940
  export function releaseRegistrySession(key: string): void {
869
941
  loadTokens.delete(key);
870
942
  if (currentKey === key) currentKey = undefined;
871
943
  pendingInits.delete(key);
872
944
  sidecarByKey.delete(key);
945
+ sessionFileByKey.delete(key);
873
946
  registries.delete(key);
874
947
  }
875
948
 
949
+ function isClaimedSidecar(sidecar: string): boolean {
950
+ for (const claimed of sidecarByKey.values()) {
951
+ if (claimed === sidecar) return true;
952
+ }
953
+ return false;
954
+ }
955
+
876
956
  export async function gcRegistrySidecars(): Promise<void> {
877
957
  let names: string[];
878
958
  try {
@@ -894,10 +974,11 @@ export async function gcRegistrySidecars(): Promise<void> {
894
974
  }
895
975
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
896
976
  const sidecar = join(sessionClaimsDir(), name);
977
+ if (isClaimedSidecar(sidecar)) continue;
897
978
  try {
898
- const header = JSON.parse(await readSidecarHeader(sidecar) || "{}") as { kind?: string; sessionFile?: string };
899
- if (header.kind !== "session" || !header.sessionFile) continue;
900
- await stat(header.sessionFile);
979
+ const sessionFile = await readSidecarSessionFile(sidecar);
980
+ if (sessionFile === undefined) continue;
981
+ await stat(sessionFile);
901
982
  } catch (error) {
902
983
  if (errCode(error) === "ENOENT") {
903
984
  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);