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 +1 -1
- package/src/anchor-registry.ts +70 -16
- package/src/fs-write.ts +4 -2
- package/src/hash-store/retry.ts +1 -1
- package/src/hash-store.ts +10 -4
- package/src/replace-undo.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.2.
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
260
|
-
out.push(JSON.stringify({ kind: "allocate", path, rows:
|
|
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 (
|
|
265
|
-
out.push(JSON.stringify({ kind: "minted", anchors:
|
|
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
|
-
|
|
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
|
|
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
|
|
899
|
-
if (
|
|
900
|
-
await stat(
|
|
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
|
-
|
|
197
|
-
|
|
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) {
|
package/src/hash-store/retry.ts
CHANGED
|
@@ -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
|
|
package/src/replace-undo.ts
CHANGED
|
@@ -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);
|