pi-hashline-edit-pro 4.2.8 → 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.8",
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",
@@ -158,7 +158,7 @@ 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;
@@ -247,6 +247,45 @@ export function shouldCompactSidecar(raw: string): boolean {
247
247
  for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) lines += 1;
248
248
  return lines >= SIDECAR_COMPACT_LINES;
249
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
+
250
289
  export function buildCompactedLog(sessionFile: string, state: SessionState): string {
251
290
  const byPath = new Map<string, Array<[string, string]>>();
252
291
  for (const [anchor, entry] of state.owned) {
@@ -256,18 +295,18 @@ export function buildCompactedLog(sessionFile: string, state: SessionState): str
256
295
  }
257
296
  const out: string[] = [JSON.stringify({ kind: "session", sessionFile })];
258
297
  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) }));
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 }));
261
300
  }
262
301
  }
263
302
  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) }));
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 }));
266
305
  }
267
306
  return out.join("\n") + "\n";
268
307
  }
269
- async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState): Promise<void> {
270
- 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;
271
310
  const tmp = `${sidecar}.compact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
272
311
  try {
273
312
  const compacted = buildCompactedLog(sessionFile, state);
@@ -300,7 +339,7 @@ async function loadRegistryState(key: string, sessionFile: string, token: object
300
339
  registries.set(key, folded);
301
340
  sidecarByKey.set(key, sidecar);
302
341
  if (rawLog.length > 0) {
303
- await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded);
342
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, firstLineSessionFile(rawLog) !== sessionFile);
304
343
  }
305
344
  try {
306
345
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
@@ -846,7 +885,7 @@ export function resetRegistryForTests(): void {
846
885
  registries.clear();
847
886
  }
848
887
 
849
- export async function readSidecarHeader(sidecar: string): Promise<string> {
888
+ async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
850
889
  const handle = await open(sidecar, "r");
851
890
  const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
852
891
  try {
@@ -855,16 +894,31 @@ export async function readSidecarHeader(sidecar: string): Promise<string> {
855
894
  const { bytesRead } = await handle.read(buffer, readBytes, Math.min(SIDECAR_HEADER_CHUNK, buffer.length - readBytes), readBytes);
856
895
  if (bytesRead === 0) break;
857
896
  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
897
  }
862
- return readBytes === buffer.length ? "" : buffer.subarray(0, readBytes).toString("utf-8");
898
+ return { text: buffer.subarray(0, readBytes).toString("utf-8"), filled: readBytes === buffer.length };
863
899
  } finally {
864
900
  await handle.close();
865
901
  }
866
902
  }
867
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
+
868
922
  export function releaseRegistrySession(key: string): void {
869
923
  loadTokens.delete(key);
870
924
  if (currentKey === key) currentKey = undefined;
@@ -895,9 +949,9 @@ export async function gcRegistrySidecars(): Promise<void> {
895
949
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
896
950
  const sidecar = join(sessionClaimsDir(), name);
897
951
  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);
952
+ const sessionFile = await readSidecarSessionFile(sidecar);
953
+ if (sessionFile === undefined) continue;
954
+ await stat(sessionFile);
901
955
  } catch (error) {
902
956
  if (errCode(error) === "ENOENT") {
903
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);