pi-export-my-chat 1.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/index.ts ADDED
@@ -0,0 +1,510 @@
1
+ /**
2
+ * export-my-chat — complete, lossless, revivable session export for pi
3
+ *
4
+ * /export-my-chat [<absolute .json path>]
5
+ * Writes ONE self-contained JSON file: the durable session (header + the
6
+ * complete ordered entry tree, active branch, active context), EVERY
7
+ * provider request observed this runtime (deduplicated by content hash),
8
+ * usage/cost/context stats, and operation timings. Refuses to run while
9
+ * pi is not idle (the "at rest" contract), never overwrites, writes 0600.
10
+ *
11
+ * /export-my-chat:revive [<path to a previous export>] [--force]
12
+ * Rebuilds a valid pi session file from the export, saves it into pi's
13
+ * session directory for the current project, and switches the running
14
+ * pi window onto it — the chat comes back, named, branchable, resumable
15
+ * via /resume. Header cwd is re-rooted to the current project; the session
16
+ * UUID is kept across machines and re-minted where it would collide;
17
+ * integrity is proven by a sha256 over the canonical JSONL that both
18
+ * export and revive build with the same code.
19
+ *
20
+ * Design invariants (see README for the full rationale):
21
+ * - Lossless in content AND count: every observed request is recorded;
22
+ * byte-identical payloads are stored once and referenced by sha256.
23
+ * - The revivable core (header + ordered entries) carries a checksum that
24
+ * revive recomputes and refuses to proceed without.
25
+ * - Versioned on three axes (export schema, session header version,
26
+ * extension version) so a newer source never silently half-loads.
27
+ * - Writes are strict (absolute, .json, exclusive, 0600); reads are
28
+ * forgiving (revive accepts relative paths).
29
+ */
30
+
31
+ import { randomUUID } from "node:crypto";
32
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
33
+ import { promises as fsp } from "node:fs";
34
+ import { dirname, join, resolve } from "node:path";
35
+ import { CURRENT_SESSION_VERSION, buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
36
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
38
+ import {
39
+ EXPORT_FORMAT,
40
+ SCHEMA_VERSION,
41
+ buildRevivePlan,
42
+ buildSessionJsonl,
43
+ collectTimingRecords,
44
+ collectUsage,
45
+ contextUsageSnapshot,
46
+ errorMessage,
47
+ parseReviveArgs,
48
+ resolveExportPath,
49
+ sha256Hex,
50
+ summarizeTimings,
51
+ timestampedExportName,
52
+ validateExportDocument,
53
+ writeExclusiveJson,
54
+ } from "./helpers.mjs";
55
+
56
+ const EXTENSION_VERSION = "1.0.0";
57
+ const SCRATCH_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
58
+
59
+ // ─── Request capture (scratch journal) ───────────────────────────────
60
+ //
61
+ // Every provider request is serialized and appended to a per-session scratch
62
+ // file the moment it is observed. Keeping the journal on disk (rather than in
63
+ // memory) bounds RAM: a tool-heavy session re-sends nearly identical context on
64
+ // every turn, so "all requests" can mean tens of MB that never needs to sit in
65
+ // a heap. The journal is reset on session_start and only merged + deduplicated
66
+ // at export time. It lives under the pi agent cache dir, is written 0600 (it
67
+ // holds the same sensitive payloads the export does), and journals untouched
68
+ // for more than SCRATCH_MAX_AGE_MS are pruned on session_start.
69
+
70
+ function scratchDir(): string {
71
+ return join(getAgentDir(), "cache", "export-my-chat");
72
+ }
73
+
74
+ function scratchPathFor(sessionId: string): string {
75
+ return join(scratchDir(), `${sessionId}.jsonl`);
76
+ }
77
+
78
+ /** Best-effort removal of stale journals; never fatal, never touches the active one. */
79
+ function pruneScratchJournals(now = Date.now()): void {
80
+ let names: string[];
81
+ try {
82
+ names = readdirSync(scratchDir());
83
+ } catch {
84
+ return; // cache dir absent — nothing to prune
85
+ }
86
+ for (const name of names) {
87
+ try {
88
+ const info = statSync(join(scratchDir(), name));
89
+ if (info.isFile() && now - info.mtimeMs > SCRATCH_MAX_AGE_MS) {
90
+ unlinkSync(join(scratchDir(), name));
91
+ }
92
+ } catch {
93
+ // A raced deletion or an unreadable entry is ignored.
94
+ }
95
+ }
96
+ }
97
+
98
+ type ContextUsageSnapshot = {
99
+ used: number | null;
100
+ window: number;
101
+ remaining: number | null;
102
+ percentUsed: number | null;
103
+ };
104
+
105
+ type ScratchRecord = {
106
+ n: number;
107
+ capturedAt: string;
108
+ provider?: string;
109
+ model?: string;
110
+ api?: string;
111
+ contextUsage: ContextUsageSnapshot;
112
+ payloadSha256: string;
113
+ serializedPayload: string;
114
+ };
115
+
116
+ function readScratchRecords(scratchPath: string | undefined): ScratchRecord[] {
117
+ if (!scratchPath || !existsSync(scratchPath)) return [];
118
+ const records: ScratchRecord[] = [];
119
+ const content = readFileSync(scratchPath, "utf8");
120
+ for (const line of content.split("\n")) {
121
+ const trimmed = line.trim();
122
+ if (!trimmed) continue;
123
+ try {
124
+ records.push(JSON.parse(trimmed) as ScratchRecord);
125
+ } catch {
126
+ // A torn final line (crash mid-append) is skipped, never fatal.
127
+ }
128
+ }
129
+ return records;
130
+ }
131
+
132
+ // ─── Formatting helpers ──────────────────────────────────────────────────────
133
+
134
+ function formatBytes(bytes: number): string {
135
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
136
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
137
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
138
+ return `${bytes} B`;
139
+ }
140
+
141
+ // ─── Argument autocompletion ──────────────────────────────────────────────────
142
+
143
+ /** Offer .json files (as absolute paths) from the current project directory. */
144
+ function jsonFileCompletions(cwd: string, prefix: string): AutocompleteItem[] | null {
145
+ try {
146
+ const files = readdirSync(cwd)
147
+ .filter((name) => name.toLowerCase().endsWith(".json"))
148
+ .map((name) => join(cwd, name));
149
+ const filtered = files.filter((file) => file.startsWith(prefix));
150
+ if (filtered.length === 0) return null;
151
+ return filtered.map((file) => ({ value: file, label: file }));
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ // ─── Extension ───────────────────────────────────────────────────────────────
158
+
159
+ export default function exportMyChat(pi: ExtensionAPI): void {
160
+ let captureStartedAt = new Date().toISOString();
161
+ let observedRequestCount = 0;
162
+ let latestCaptureError: string | undefined;
163
+ let scratchPath: string | undefined;
164
+ let currentCwd: string | undefined;
165
+
166
+ /**
167
+ * Create/truncate the journal for the current session without touching
168
+ * the capture counters or window (those belong to session_start).
169
+ */
170
+ const ensureScratch = (ctx: any): void => {
171
+ if (scratchPath) return;
172
+ try {
173
+ const id = ctx?.sessionManager?.getSessionId?.();
174
+ if (typeof id === "string" && id.length > 0) {
175
+ scratchPath = scratchPathFor(id);
176
+ mkdirSync(dirname(scratchPath), { recursive: true, mode: 0o700 });
177
+ writeFileSync(scratchPath, "", { mode: 0o600 }); // truncate: fresh journal
178
+ }
179
+ } catch {
180
+ scratchPath = undefined; // capture is best-effort; export still works
181
+ }
182
+ };
183
+
184
+ const resetCapture = (ctx: any): void => {
185
+ captureStartedAt = new Date().toISOString();
186
+ observedRequestCount = 0;
187
+ latestCaptureError = undefined;
188
+ scratchPath = undefined;
189
+ ensureScratch(ctx);
190
+ pruneScratchJournals();
191
+ };
192
+
193
+ pi.on("session_start", (_event, ctx) => {
194
+ currentCwd = ctx?.cwd ?? currentCwd;
195
+ resetCapture(ctx);
196
+ });
197
+
198
+ pi.on("before_provider_request", (event: any, ctx: any) => {
199
+ observedRequestCount++;
200
+ // Late initialization (e.g. session id became available after load):
201
+ // the journal is created, but the capture window and counters — which
202
+ // belong to session_start — are left untouched.
203
+ ensureScratch(ctx);
204
+ if (!scratchPath) {
205
+ latestCaptureError =
206
+ "Request journal unavailable (no session id); requests are counted but not recorded.";
207
+ return;
208
+ }
209
+ try {
210
+ const serializedPayload = JSON.stringify(event.payload);
211
+ if (serializedPayload === undefined) throw new Error("provider payload serialized to undefined");
212
+ const model = ctx?.model as { provider?: string; id?: string; api?: string; contextWindow?: number } | undefined;
213
+ const record: ScratchRecord = {
214
+ n: observedRequestCount,
215
+ capturedAt: new Date().toISOString(),
216
+ provider: model?.provider,
217
+ model: model?.id,
218
+ api: model?.api,
219
+ contextUsage: contextUsageSnapshot(ctx?.getContextUsage?.(), model?.contextWindow),
220
+ payloadSha256: sha256Hex(serializedPayload),
221
+ serializedPayload,
222
+ };
223
+ appendFileSync(scratchPath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
224
+ latestCaptureError = undefined;
225
+ } catch (error) {
226
+ latestCaptureError = errorMessage(error);
227
+ }
228
+ });
229
+
230
+ // ─── /export-my-chat ──────────────────────────────────────────────────────
231
+
232
+ pi.registerCommand("export-my-chat", {
233
+ description:
234
+ "Export the complete session (durable tree + every provider request + stats + timings) to one lossless, revivable JSON file",
235
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null =>
236
+ currentCwd ? jsonFileCompletions(currentCwd, prefix) : null,
237
+ handler: async (args, ctx) => {
238
+ try {
239
+ // "At rest" contract: export refuses while a turn is running.
240
+ if (ctx.isIdle() !== true) {
241
+ ctx.ui.notify(
242
+ "Export refused: pi is not idle. Wait for the current turn (or press Esc), then run /export-my-chat again.",
243
+ "error",
244
+ );
245
+ return;
246
+ }
247
+ currentCwd = ctx.cwd;
248
+
249
+ const destination = resolveExportPath(args ?? "", ctx.cwd);
250
+
251
+ const sessionManager: any = ctx.sessionManager;
252
+ const header = sessionManager.getHeader();
253
+ if (!header || typeof header !== "object") {
254
+ ctx.ui.notify("Export failed: no session header is available yet.", "error");
255
+ return;
256
+ }
257
+ const entries = sessionManager.getEntries();
258
+ const activeBranch = sessionManager.getBranch();
259
+ const name = sessionManager.getSessionName() ?? null;
260
+ const sessionFile = sessionManager.getSessionFile() ?? null;
261
+ const leafId = sessionManager.getLeafId() ?? null;
262
+ // buildSessionContext is pi's own exported, pure context
263
+ // builder (the live sessionManager also exposes it as a
264
+ // method, but the free function is the type-safe route).
265
+ const activeContext = buildSessionContext(entries, leafId);
266
+
267
+ // The revivable core and its proof, built with the exact
268
+ // serialization revive will use. Also compare against pi's own
269
+ // on-disk file as a diagnostic: a mismatch never blocks the
270
+ // export (the checksum is over OUR canonical form, which is
271
+ // what revive writes), but it tells you the file drifted from
272
+ // the entries — e.g. an in-flight write or an edited .jsonl.
273
+ const canonicalJsonl = buildSessionJsonl(header, entries);
274
+ let matchesOriginalFile: boolean | null = null;
275
+ if (typeof sessionFile === "string" && existsSync(sessionFile)) {
276
+ try {
277
+ matchesOriginalFile = readFileSync(sessionFile, "utf8") === canonicalJsonl;
278
+ } catch {
279
+ matchesOriginalFile = null;
280
+ }
281
+ }
282
+
283
+ // Every observed provider request, with byte-identical
284
+ // payloads deduplicated into a store keyed by sha256. The
285
+ // request list keeps content AND count; only literal duplicate
286
+ // bytes are factored out.
287
+ const scratchRecords = readScratchRecords(scratchPath);
288
+ const payloads: Record<string, unknown> = {};
289
+ const requestRecords = scratchRecords.map((record) => {
290
+ if (!(record.payloadSha256 in payloads)) {
291
+ payloads[record.payloadSha256] = JSON.parse(record.serializedPayload);
292
+ }
293
+ const { serializedPayload: _omit, ...publicRecord } = record;
294
+ return publicRecord;
295
+ });
296
+
297
+ const activeTimings = collectTimingRecords(activeBranch);
298
+ const wholeSessionTimings = collectTimingRecords(entries);
299
+
300
+ const document = {
301
+ format: EXPORT_FORMAT,
302
+ schemaVersion: SCHEMA_VERSION,
303
+ exportedAt: new Date().toISOString(),
304
+ exportedBy: {
305
+ extension: "export-my-chat",
306
+ extensionVersion: EXTENSION_VERSION,
307
+ sessionHeaderVersion: typeof header?.version === "number" ? header.version : null,
308
+ currentSessionVersion: CURRENT_SESSION_VERSION,
309
+ },
310
+ session: {
311
+ id: sessionManager.getSessionId(),
312
+ name,
313
+ cwd: sessionManager.getCwd(),
314
+ sessionFile,
315
+ header,
316
+ leafId,
317
+ entries,
318
+ activeBranchEntryIds: activeBranch
319
+ .map((entry: any) => entry?.id)
320
+ .filter((id: unknown): id is string => typeof id === "string"),
321
+ activeContext,
322
+ revive: {
323
+ checksum: sha256Hex(canonicalJsonl),
324
+ headerVersion: typeof header?.version === "number" ? header.version : null,
325
+ entryCount: entries.length,
326
+ lastEntryId: entries.length > 0 ? entries[entries.length - 1]?.id ?? null : null,
327
+ matchesOriginalFile,
328
+ note:
329
+ "Rebuild with /export-my-chat:revive. The checksum is sha256 over the canonical JSONL (header line + one compact JSON line per entry, insertion order) that revive writes; it covers the lossless core only — declared revival transformations (cwd re-root, name re-attach) are applied after verification.",
330
+ },
331
+ },
332
+ requests: {
333
+ captureStartedAt,
334
+ observedRequestCount,
335
+ scratchFile: scratchPath ?? null,
336
+ latestCaptureError: latestCaptureError ?? null,
337
+ records: requestRecords,
338
+ payloads,
339
+ note:
340
+ "Every provider request observed by this extension instance, in order, each with a live context-usage snapshot. Byte-identical payloads are stored once in payloads, keyed by sha256 of the serialized request — content and count are preserved, only duplicate bytes are factored out. Auth headers are never part of a request payload; a response is not part of the request that produced it; later-loaded extensions may mutate the payload after this hook observes it. Capture resets on session_start, reload, or resume.",
341
+ },
342
+ stats: {
343
+ source:
344
+ "Persisted assistant, tool-result, compaction, and branch-summary usage; timing records are excluded to avoid double counting.",
345
+ activeBranch: collectUsage(activeBranch),
346
+ wholeSession: collectUsage(entries),
347
+ liveContext: contextUsageSnapshot(ctx.getContextUsage(), ctx.model?.contextWindow),
348
+ },
349
+ timings: {
350
+ source: "Persisted operation-timing records produced by the timings extension, if installed.",
351
+ activeBranch: {
352
+ records: activeTimings,
353
+ summary: summarizeTimings(activeTimings),
354
+ },
355
+ wholeSession: {
356
+ records: wholeSessionTimings,
357
+ summary: summarizeTimings(wholeSessionTimings),
358
+ },
359
+ },
360
+ };
361
+
362
+ const bytes = await writeExclusiveJson(destination, document);
363
+ ctx.ui.notify(
364
+ `Exported ${entries.length} session entries, ${requestRecords.length} provider requests ` +
365
+ `(${Object.keys(payloads).length} unique payloads), ${wholeSessionTimings.length} timing records ` +
366
+ `— ${formatBytes(bytes)} — to:\n${destination}`,
367
+ "info",
368
+ );
369
+ } catch (error) {
370
+ ctx.ui.notify(`Export failed: ${errorMessage(error)}`, "error");
371
+ }
372
+ },
373
+ });
374
+
375
+ // ─── /export-my-chat:revive ───────────────────────────────────────────────
376
+
377
+ pi.registerCommand("export-my-chat:revive", {
378
+ description:
379
+ "Revive a chat from an /export-my-chat JSON: rebuilds a session file into pi's session dir and switches this window onto it",
380
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null =>
381
+ currentCwd ? jsonFileCompletions(currentCwd, prefix) : null,
382
+ handler: async (args, ctx) => {
383
+ try {
384
+ // Same "at rest" contract as export — checked first so a
385
+ // mid-turn revive fails fast, before any reading.
386
+ if (ctx.isIdle() !== true) {
387
+ ctx.ui.notify(
388
+ "Revive refused: pi is not idle. Wait for the current turn (or press Esc), then run it again.",
389
+ "error",
390
+ );
391
+ return;
392
+ }
393
+ currentCwd = ctx.cwd;
394
+
395
+ const parsedArgs = parseReviveArgs(args ?? "", ctx.cwd);
396
+ if (!parsedArgs.ok) {
397
+ ctx.ui.notify(parsedArgs.error ?? "Invalid arguments.", "error");
398
+ return;
399
+ }
400
+
401
+ // Read and validate the export before touching any state.
402
+ let raw: string;
403
+ try {
404
+ const info = statSync(parsedArgs.path);
405
+ if (!info.isFile()) throw new Error("path is not a regular file");
406
+ raw = await fsp.readFile(parsedArgs.path, "utf8");
407
+ } catch (error) {
408
+ ctx.ui.notify(`Cannot read export: ${errorMessage(error)}`, "error");
409
+ return;
410
+ }
411
+
412
+ let doc: any;
413
+ try {
414
+ doc = JSON.parse(raw);
415
+ } catch (error) {
416
+ ctx.ui.notify(`Export is not valid JSON: ${errorMessage(error)}`, "error");
417
+ return;
418
+ }
419
+
420
+ const validation = validateExportDocument(doc, { maxHeaderVersion: CURRENT_SESSION_VERSION });
421
+ if (!validation.ok) {
422
+ ctx.ui.notify(`Refusing to revive — ${validation.errors.join(" ")}`, "error");
423
+ return;
424
+ }
425
+
426
+ // Replacing a non-empty chat is destructive to the current
427
+ // window's context (the old session stays on disk). Confirm
428
+ // unless --force. Headless mode (no dialog UI) cannot confirm,
429
+ // so it must pass --force — ui.confirm exists everywhere but is
430
+ // a silent no-op returning false without a UI.
431
+ const currentEntries = ctx.sessionManager.getEntries();
432
+ if (currentEntries.length > 0 && !parsedArgs.force) {
433
+ let confirmed = false;
434
+ if (ctx.hasUI) {
435
+ confirmed = await ctx.ui.confirm(
436
+ "Revive into this window?",
437
+ `The current session has ${currentEntries.length} entries and will be replaced ` +
438
+ `(it remains saved on disk). Continue?`,
439
+ );
440
+ }
441
+ if (!confirmed) {
442
+ ctx.ui.notify(
443
+ "Revive cancelled. Use /export-my-chat:revive --force to skip this prompt " +
444
+ "(required in headless mode).",
445
+ "info",
446
+ );
447
+ return;
448
+ }
449
+ }
450
+
451
+ const sessionManager: any = ctx.sessionManager;
452
+ const sessionDir: string = sessionManager.getSessionDir();
453
+ const uuidExists = (uuid: string): boolean => {
454
+ try {
455
+ return readdirSync(sessionDir).some((name) => name.endsWith(`_${uuid}.jsonl`));
456
+ } catch {
457
+ return false;
458
+ }
459
+ };
460
+
461
+ const plan = buildRevivePlan(doc, {
462
+ cwd: ctx.cwd,
463
+ sessionDir,
464
+ uuidExists,
465
+ newUUID: () => randomUUID(),
466
+ });
467
+
468
+ // Exclusive, private write into pi's session dir, then switch
469
+ // this window onto the revived session. All post-switch work
470
+ // must go through the withSession ctx (the old ctx is stale).
471
+ mkdirSync(sessionDir, { recursive: true }); // defensive; pi normally creates it
472
+ let handle: any;
473
+ try {
474
+ handle = await fsp.open(plan.filePath, "wx", 0o600);
475
+ await handle.writeFile(plan.jsonl, "utf8");
476
+ await handle.sync();
477
+ await handle.close();
478
+ handle = undefined;
479
+ } catch (error) {
480
+ await handle?.close().catch(() => {});
481
+ ctx.ui.notify(`Failed to write revived session: ${errorMessage(error)}`, "error");
482
+ return;
483
+ }
484
+
485
+ const summary =
486
+ `Revived ${plan.entryCount} entries` +
487
+ (plan.appendedName ? `, re-attached name "${plan.appendedName}"` : "") +
488
+ ` → ${plan.filePath}. The session remembers files from ${doc.session.cwd ?? "(unknown cwd)"}.`;
489
+
490
+ // Another extension (or a project-trust prompt) can cancel the
491
+ // switch. The revived file is already safely on disk, so say so
492
+ // instead of failing silently.
493
+ const result = await ctx.switchSession(plan.filePath, {
494
+ withSession: async (revivedCtx) => {
495
+ revivedCtx.ui.notify(summary, "info");
496
+ },
497
+ });
498
+ if (result.cancelled) {
499
+ ctx.ui.notify(
500
+ `Switch to the revived session was cancelled — the rebuilt session is saved and ` +
501
+ `available via /resume:\n${plan.filePath}`,
502
+ "warning",
503
+ );
504
+ }
505
+ } catch (error) {
506
+ ctx.ui.notify(`Revive failed: ${errorMessage(error)}`, "error");
507
+ }
508
+ },
509
+ });
510
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "pi-export-my-chat",
3
+ "version": "1.0.0",
4
+ "description": "pi extension that exports a running chat to one complete, lossless, revivable JSON — the full session tree, every provider request observed (content-hash deduplicated), usage/cost/context stats, and timings — and revives that JSON back into a live, named pi session on any machine via /export-my-chat:revive.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "session",
10
+ "export",
11
+ "backup",
12
+ "portable",
13
+ "resume"
14
+ ],
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "files": ["*"],
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-coding-agent": "*"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test tests/helpers.test.mjs"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "pi": {
28
+ "extensions": ["./index.ts"]
29
+ }
30
+ }