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/LICENSE +21 -0
- package/README.md +277 -0
- package/docs/export-format.md +140 -0
- package/helpers.mjs +509 -0
- package/index.ts +510 -0
- package/package.json +30 -0
- package/tests/helpers.test.mjs +345 -0
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
|
+
}
|