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/helpers.mjs
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for export-my-chat.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is dependency-free (node builtins only) and side-effect-light
|
|
5
|
+
* so it can be unit-tested directly with `node --test`. The extension's index.ts
|
|
6
|
+
* imports these; the tests import them too. No pi imports live in this file —
|
|
7
|
+
* that keeps the logic portable and testable outside a pi runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { lstat, open, stat, unlink } from "node:fs/promises";
|
|
12
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export const EXPORT_FORMAT = "pi-my-chat-export";
|
|
17
|
+
export const SCHEMA_VERSION = 1;
|
|
18
|
+
const TIMING_ENTRY_TYPE = "operation-timing";
|
|
19
|
+
|
|
20
|
+
// ─── Small utilities ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export function sha256Hex(text) {
|
|
23
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function errorMessage(error) {
|
|
27
|
+
return error instanceof Error ? error.message : String(error);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 8-char hex entry id, matching pi's SessionEntryBase.id convention. */
|
|
31
|
+
export function makeEntryId() {
|
|
32
|
+
return randomBytes(4).toString("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Filesystem-safe UTC ISO timestamp for export names: 20260413T123456-789Z */
|
|
36
|
+
export function fileTimestamp(date = new Date()) {
|
|
37
|
+
return date.toISOString().replace(/[-:]/g, "").replace(".", "-");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function timestampedExportName(date = new Date()) {
|
|
41
|
+
return `pi-my-chat-export-${fileTimestamp(date)}.json`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The timestamp portion of pi's own session filename convention, byte-for-byte:
|
|
46
|
+
* `new Date().toISOString().replace(/[:.]/g, "-")` (e.g. 2026-04-13T12-34-56-789Z).
|
|
47
|
+
* Revived sessions must be named exactly like pi names its own so they are
|
|
48
|
+
* indistinguishable in the session directory.
|
|
49
|
+
*/
|
|
50
|
+
export function sessionFileTimestamp(date = new Date()) {
|
|
51
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function finiteNonNegative(value) {
|
|
55
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Usage accounting ───────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function emptyUsageTotals() {
|
|
61
|
+
return {
|
|
62
|
+
usageRecords: 0,
|
|
63
|
+
input: 0,
|
|
64
|
+
output: 0,
|
|
65
|
+
cacheRead: 0,
|
|
66
|
+
cacheWrite: 0,
|
|
67
|
+
totalTokens: 0,
|
|
68
|
+
cost: {
|
|
69
|
+
input: 0,
|
|
70
|
+
output: 0,
|
|
71
|
+
cacheRead: 0,
|
|
72
|
+
cacheWrite: 0,
|
|
73
|
+
total: 0,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addUsage(totals, raw) {
|
|
79
|
+
if (!raw || typeof raw !== "object") return;
|
|
80
|
+
const input = finiteNonNegative(raw.input);
|
|
81
|
+
const output = finiteNonNegative(raw.output);
|
|
82
|
+
const cacheRead = finiteNonNegative(raw.cacheRead);
|
|
83
|
+
const cacheWrite = finiteNonNegative(raw.cacheWrite);
|
|
84
|
+
const calculatedTokens = input + output + cacheRead + cacheWrite;
|
|
85
|
+
const reportedTokens = finiteNonNegative(raw.totalTokens);
|
|
86
|
+
const rawCost = raw.cost && typeof raw.cost === "object" ? raw.cost : {};
|
|
87
|
+
const inputCost = finiteNonNegative(rawCost.input);
|
|
88
|
+
const outputCost = finiteNonNegative(rawCost.output);
|
|
89
|
+
const cacheReadCost = finiteNonNegative(rawCost.cacheRead);
|
|
90
|
+
const cacheWriteCost = finiteNonNegative(rawCost.cacheWrite);
|
|
91
|
+
const calculatedCost = inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
92
|
+
const reportedCost = finiteNonNegative(rawCost.total);
|
|
93
|
+
|
|
94
|
+
totals.usageRecords++;
|
|
95
|
+
totals.input += input;
|
|
96
|
+
totals.output += output;
|
|
97
|
+
totals.cacheRead += cacheRead;
|
|
98
|
+
totals.cacheWrite += cacheWrite;
|
|
99
|
+
// Prefer the provider-reported total when present; fall back to the sum of
|
|
100
|
+
// components. Either way the value is clamped to a finite non-negative.
|
|
101
|
+
totals.totalTokens += reportedTokens || calculatedTokens;
|
|
102
|
+
totals.cost.input += inputCost;
|
|
103
|
+
totals.cost.output += outputCost;
|
|
104
|
+
totals.cost.cacheRead += cacheReadCost;
|
|
105
|
+
totals.cost.cacheWrite += cacheWriteCost;
|
|
106
|
+
totals.cost.total += reportedCost || calculatedCost;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sum authoritative persisted usage without counting timing records, whose
|
|
111
|
+
* agent rows overlap child operations. Counts assistant messages, nested model
|
|
112
|
+
* usage reported by tool results, compaction, and branch summaries — each of
|
|
113
|
+
* which carries a `usage` object in the session tree.
|
|
114
|
+
*/
|
|
115
|
+
export function collectUsage(entries) {
|
|
116
|
+
const totals = emptyUsageTotals();
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry?.type === "message") {
|
|
119
|
+
const message = entry.message;
|
|
120
|
+
if (message?.role === "assistant" || message?.role === "toolResult") addUsage(totals, message.usage);
|
|
121
|
+
} else if (entry?.type === "compaction" || entry?.type === "branch_summary") {
|
|
122
|
+
addUsage(totals, entry.usage);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return totals;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Timing telemetry ───────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export function isTimingRecord(value) {
|
|
131
|
+
return Boolean(
|
|
132
|
+
value &&
|
|
133
|
+
typeof value === "object" &&
|
|
134
|
+
value.schemaVersion === 1 &&
|
|
135
|
+
typeof value.operationId === "string" &&
|
|
136
|
+
typeof value.kind === "string" &&
|
|
137
|
+
typeof value.startedAt === "number" &&
|
|
138
|
+
typeof value.endedAt === "number" &&
|
|
139
|
+
typeof value.durationMs === "number",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function collectTimingRecords(entries) {
|
|
144
|
+
return entries
|
|
145
|
+
.filter((entry) => entry?.type === "custom" && entry.customType === TIMING_ENTRY_TYPE && isTimingRecord(entry.data))
|
|
146
|
+
.map((entry) => entry.data);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Per-kind duration summaries. Kinds are kept separate on purpose: an agent
|
|
151
|
+
* total overlaps its child model/tool records, so summing kinds would
|
|
152
|
+
* double-count elapsed wall time.
|
|
153
|
+
*/
|
|
154
|
+
export function summarizeTimings(records) {
|
|
155
|
+
const byKind = {};
|
|
156
|
+
for (const record of records) {
|
|
157
|
+
const kind = record.kind;
|
|
158
|
+
const durationMs = finiteNonNegative(record.durationMs);
|
|
159
|
+
const current = byKind[kind] ?? {
|
|
160
|
+
count: 0,
|
|
161
|
+
totalDurationMs: 0,
|
|
162
|
+
averageDurationMs: 0,
|
|
163
|
+
minDurationMs: durationMs,
|
|
164
|
+
maxDurationMs: durationMs,
|
|
165
|
+
statuses: {},
|
|
166
|
+
};
|
|
167
|
+
current.count++;
|
|
168
|
+
current.totalDurationMs += durationMs;
|
|
169
|
+
current.minDurationMs = Math.min(current.minDurationMs, durationMs);
|
|
170
|
+
current.maxDurationMs = Math.max(current.maxDurationMs, durationMs);
|
|
171
|
+
const status = typeof record.status === "string" ? record.status : "unknown";
|
|
172
|
+
current.statuses[status] = (current.statuses[status] ?? 0) + 1;
|
|
173
|
+
byKind[kind] = current;
|
|
174
|
+
}
|
|
175
|
+
for (const summary of Object.values(byKind)) {
|
|
176
|
+
summary.averageDurationMs = summary.count > 0 ? summary.totalDurationMs / summary.count : 0;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
recordCount: records.length,
|
|
180
|
+
byKind,
|
|
181
|
+
note: "Durations for agent, model, tool, compaction, and user-wait records can overlap; do not sum different kinds as elapsed wall time.",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Context window snapshot ─────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Live context-window snapshot: what the provider would receive right now,
|
|
189
|
+
* versus what has been persisted historically. `contextUsage` is pi's
|
|
190
|
+
* getContextUsage() shape ({ tokens }); `contextWindow` is the model's window.
|
|
191
|
+
*/
|
|
192
|
+
export function contextUsageSnapshot(contextUsage, contextWindow) {
|
|
193
|
+
// The window may come from the model or from the usage snapshot itself;
|
|
194
|
+
// prefer the explicit model window, fall back to the snapshot's own.
|
|
195
|
+
const candidates = [contextWindow, contextUsage?.contextWindow];
|
|
196
|
+
const window = candidates.find(
|
|
197
|
+
(value) => typeof value === "number" && Number.isFinite(value) && value > 0,
|
|
198
|
+
) ?? 0;
|
|
199
|
+
// pi reports tokens === null when it cannot estimate (e.g. right after
|
|
200
|
+
// compaction, before the next response). Preserve that as null — recording
|
|
201
|
+
// 0 would understate real usage.
|
|
202
|
+
const rawUsed = contextUsage?.tokens;
|
|
203
|
+
const used = typeof rawUsed === "number" && Number.isFinite(rawUsed) ? Math.max(0, rawUsed) : null;
|
|
204
|
+
return {
|
|
205
|
+
used,
|
|
206
|
+
window,
|
|
207
|
+
remaining: used === null ? null : Math.max(0, window - used),
|
|
208
|
+
percentUsed: used !== null && window > 0 ? Math.round((used / window) * 100) : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Destination path resolution (export output) ─────────────────────────────
|
|
213
|
+
|
|
214
|
+
function unwrapPathArgument(rawArgument) {
|
|
215
|
+
const trimmed = rawArgument.trim();
|
|
216
|
+
if (!trimmed) return undefined;
|
|
217
|
+
const first = trimmed[0];
|
|
218
|
+
const last = trimmed[trimmed.length - 1];
|
|
219
|
+
if (first === '"' || first === "'") {
|
|
220
|
+
if (last !== first || trimmed.length < 2) throw new Error("Path argument has an unmatched quote.");
|
|
221
|
+
const unwrapped = trimmed.slice(1, -1);
|
|
222
|
+
if (!unwrapped) throw new Error("Path argument cannot be empty.");
|
|
223
|
+
return unwrapped;
|
|
224
|
+
}
|
|
225
|
+
if (last === "'" || last === '"') throw new Error("Path argument has an unmatched quote.");
|
|
226
|
+
return trimmed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve the export destination. Rules (strict by design — this path WRITES):
|
|
231
|
+
* (none) -> <cwd>/pi-my-chat-export-<UTC timestamp>.json
|
|
232
|
+
* <absolute dir> -> invalid: must be a .json file path, not a directory
|
|
233
|
+
* <absolute .json> -> exactly that file; must not already exist
|
|
234
|
+
* relative path -> invalid: absolute paths required
|
|
235
|
+
* Never overwrites; never creates parent directories.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveExportPath(rawArgument, cwd, date = new Date()) {
|
|
238
|
+
const supplied = unwrapPathArgument(rawArgument ?? "");
|
|
239
|
+
if (!supplied) return join(resolve(cwd), timestampedExportName(date));
|
|
240
|
+
if (supplied.includes("\0")) throw new Error("Export destination cannot contain a NUL byte.");
|
|
241
|
+
if (!isAbsolute(supplied)) {
|
|
242
|
+
throw new Error(`Export destination must be an absolute path: ${supplied}`);
|
|
243
|
+
}
|
|
244
|
+
const resolved = resolve(supplied);
|
|
245
|
+
if (!resolved.toLowerCase().endsWith(".json")) {
|
|
246
|
+
throw new Error(`Export destination must have a .json extension: ${supplied}`);
|
|
247
|
+
}
|
|
248
|
+
return resolved;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parse the revive input path (this path is READ, so it is forgiving where the
|
|
253
|
+
* export destination is strict): relative paths resolve against cwd, quoted
|
|
254
|
+
* paths unwrap. Returns { path, force, ok, error }.
|
|
255
|
+
*/
|
|
256
|
+
export function parseReviveArgs(rawArgument, cwd) {
|
|
257
|
+
let force = false;
|
|
258
|
+
let rest = (rawArgument ?? "").trim();
|
|
259
|
+
|
|
260
|
+
// Pull --force out wherever it appears (space-separated).
|
|
261
|
+
const tokens = [];
|
|
262
|
+
for (const part of rest.split(/\s+/)) {
|
|
263
|
+
if (part === "--force") force = true;
|
|
264
|
+
else if (part) tokens.push(part);
|
|
265
|
+
}
|
|
266
|
+
rest = tokens.join(" ");
|
|
267
|
+
|
|
268
|
+
const supplied = unwrapPathArgument(rest);
|
|
269
|
+
if (!supplied) return { ok: false, force, error: "Provide the path to an /export-my-chat JSON file." };
|
|
270
|
+
if (supplied.includes("\0")) return { ok: false, force, error: "Path cannot contain a NUL byte." };
|
|
271
|
+
return { ok: true, force, path: isAbsolute(supplied) ? resolve(supplied) : resolve(cwd, supplied) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Exclusive private JSON write ────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
async function destinationExists(path) {
|
|
277
|
+
try {
|
|
278
|
+
return await lstat(path);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error?.code === "ENOENT") return undefined;
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Validate and exclusively create a private JSON export. Partial files are removed after write failures. */
|
|
286
|
+
export async function writeExclusiveJson(destination, value) {
|
|
287
|
+
const existing = await destinationExists(destination);
|
|
288
|
+
if (existing) {
|
|
289
|
+
if (existing.isDirectory()) throw new Error(`Export destination is a directory, not a JSON file: ${destination}`);
|
|
290
|
+
if (existing.isSymbolicLink()) throw new Error(`Export destination already exists as a symbolic link: ${destination}`);
|
|
291
|
+
throw new Error(`Export destination already exists: ${destination}`);
|
|
292
|
+
}
|
|
293
|
+
if (extname(destination) !== ".json") {
|
|
294
|
+
throw new Error(`Export destination must have a .json extension: ${destination}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let parent;
|
|
298
|
+
try {
|
|
299
|
+
parent = await stat(dirname(destination));
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error?.code === "ENOENT") throw new Error(`Export parent directory does not exist: ${dirname(destination)}`);
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (!parent.isDirectory()) throw new Error(`Export parent path is not a directory: ${dirname(destination)}`);
|
|
305
|
+
|
|
306
|
+
let serialized;
|
|
307
|
+
try {
|
|
308
|
+
serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new Error(`Export data could not be serialized as JSON: ${errorMessage(error)}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let handle;
|
|
314
|
+
let created = false;
|
|
315
|
+
try {
|
|
316
|
+
handle = await open(destination, "wx", 0o600);
|
|
317
|
+
created = true;
|
|
318
|
+
await handle.writeFile(serialized, "utf8");
|
|
319
|
+
await handle.sync();
|
|
320
|
+
await handle.close();
|
|
321
|
+
handle = undefined;
|
|
322
|
+
return Buffer.byteLength(serialized);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
await handle?.close().catch(() => {});
|
|
325
|
+
if (created) await unlink(destination).catch(() => {});
|
|
326
|
+
if (error?.code === "EEXIST") throw new Error(`Export destination already exists: ${destination}`);
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Lossless session core: canonical JSONL + checksum ────────────────────────
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Rebuild the session JSONL exactly as pi persists it: header line, then each
|
|
335
|
+
* entry as one compact JSON line, in insertion order. Order matters — the last
|
|
336
|
+
* line is the active leaf, which is the session's branch position.
|
|
337
|
+
*
|
|
338
|
+
* This is the single serialization used by BOTH export (to compute the
|
|
339
|
+
* checksum) and revive (to rebuild the file), so the checksum is consistent by
|
|
340
|
+
* construction, not by convention.
|
|
341
|
+
*/
|
|
342
|
+
export function buildSessionJsonl(header, entries) {
|
|
343
|
+
const lines = [JSON.stringify(header)];
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
lines.push(JSON.stringify(entry));
|
|
346
|
+
}
|
|
347
|
+
return `${lines.join("\n")}\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Structural validation of a session core. Checks every invariant pi's loader
|
|
352
|
+
* assumes: header shape, unique ids, resolvable parentIds, and (when present)
|
|
353
|
+
* that the declared leaf is the last entry.
|
|
354
|
+
*/
|
|
355
|
+
export function validateSessionCore(header, entries, { expectedChecksum, expectedLastEntryId } = {}) {
|
|
356
|
+
const errors = [];
|
|
357
|
+
if (!header || typeof header !== "object" || header.type !== "session") {
|
|
358
|
+
errors.push("Session header is missing or malformed.");
|
|
359
|
+
}
|
|
360
|
+
if (!Array.isArray(entries)) {
|
|
361
|
+
errors.push("Session entries are missing or not an array.");
|
|
362
|
+
return { ok: false, errors };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const ids = new Set();
|
|
366
|
+
for (const [index, entry] of entries.entries()) {
|
|
367
|
+
if (!entry || typeof entry !== "object" || typeof entry.type !== "string") {
|
|
368
|
+
errors.push(`Entry at index ${index} is not an object with a type.`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (typeof entry.id !== "string" || entry.id.length === 0) {
|
|
372
|
+
errors.push(`Entry at index ${index} is missing a string id.`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (ids.has(entry.id)) errors.push(`Duplicate entry id: ${entry.id}`);
|
|
376
|
+
ids.add(entry.id);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const [index, entry] of entries.entries()) {
|
|
380
|
+
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") continue;
|
|
381
|
+
const { parentId } = entry;
|
|
382
|
+
if (parentId !== null && parentId !== undefined) {
|
|
383
|
+
if (typeof parentId !== "string" || !ids.has(parentId)) {
|
|
384
|
+
errors.push(`Entry ${entry.id} (index ${index}) references unknown parentId: ${String(parentId)}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const lastEntryId = entries.length > 0 ? entries[entries.length - 1]?.id : null;
|
|
390
|
+
if (typeof expectedLastEntryId === "string" && expectedLastEntryId !== lastEntryId) {
|
|
391
|
+
errors.push(
|
|
392
|
+
`Leaf mismatch: export declares leaf "${expectedLastEntryId}" but the last entry is "${String(lastEntryId)}".`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (typeof expectedChecksum === "string") {
|
|
397
|
+
const actual = sha256Hex(buildSessionJsonl(header, entries));
|
|
398
|
+
if (actual !== expectedChecksum) {
|
|
399
|
+
errors.push(
|
|
400
|
+
`Checksum mismatch: export declares ${expectedChecksum.slice(0, 16)}… but the entries serialize to ${actual.slice(0, 16)}… — the file is corrupt or was edited.`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { ok: errors.length === 0, errors, lastEntryId };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Full validation of an export document against this extension's expectations.
|
|
410
|
+
* `maxHeaderVersion` is pi's CURRENT_SESSION_VERSION on the reviving machine;
|
|
411
|
+
* an export made by a NEWER pi (higher session header version) is refused rather
|
|
412
|
+
* than half-loaded.
|
|
413
|
+
*/
|
|
414
|
+
export function validateExportDocument(doc, { maxHeaderVersion }) {
|
|
415
|
+
const errors = [];
|
|
416
|
+
if (!doc || typeof doc !== "object") {
|
|
417
|
+
return { ok: false, errors: ["File does not contain a JSON object."] };
|
|
418
|
+
}
|
|
419
|
+
if (doc.format !== EXPORT_FORMAT) {
|
|
420
|
+
errors.push(`Not a ${EXPORT_FORMAT} document (found format: ${JSON.stringify(doc.format)}).`);
|
|
421
|
+
return { ok: false, errors };
|
|
422
|
+
}
|
|
423
|
+
if (typeof doc.schemaVersion !== "number" || doc.schemaVersion > SCHEMA_VERSION) {
|
|
424
|
+
errors.push(
|
|
425
|
+
`Export schema version ${JSON.stringify(doc.schemaVersion)} is newer than this extension understands (${SCHEMA_VERSION}). Upgrade the extension (and pi), or re-export from the source machine.`,
|
|
426
|
+
);
|
|
427
|
+
return { ok: false, errors };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const session = doc.session;
|
|
431
|
+
if (!session || typeof session !== "object" || !Array.isArray(session.entries)) {
|
|
432
|
+
errors.push("Document has no session.entries array.");
|
|
433
|
+
return { ok: false, errors };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const header = session.header;
|
|
437
|
+
const headerVersion = typeof header?.version === "number" ? header.version : undefined;
|
|
438
|
+
if (headerVersion === undefined) {
|
|
439
|
+
errors.push("Session header has no numeric version.");
|
|
440
|
+
} else if (typeof maxHeaderVersion === "number" && headerVersion > maxHeaderVersion) {
|
|
441
|
+
errors.push(
|
|
442
|
+
`Session header version ${headerVersion} is newer than this pi supports (max ${maxHeaderVersion}). Upgrade pi on this machine.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const revive = session.revive;
|
|
447
|
+
if (typeof revive?.entryCount === "number" && revive.entryCount !== session.entries.length) {
|
|
448
|
+
errors.push(
|
|
449
|
+
`Entry count mismatch: export declares ${revive.entryCount} but the entries array has ${session.entries.length}.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const core = validateSessionCore(header, session.entries, {
|
|
453
|
+
expectedChecksum: typeof revive?.checksum === "string" ? revive.checksum : undefined,
|
|
454
|
+
expectedLastEntryId: typeof revive?.lastEntryId === "string" ? revive.lastEntryId : undefined,
|
|
455
|
+
});
|
|
456
|
+
errors.push(...core.errors);
|
|
457
|
+
|
|
458
|
+
return { ok: errors.length === 0, errors };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Build the revival plan: a rebuilt .jsonl (header re-rooted to the target cwd,
|
|
463
|
+
* original session UUID kept unless it already exists on this machine, optional
|
|
464
|
+
* session name re-attached as a session_info entry when the exported entries
|
|
465
|
+
* lost it) plus the destination path in pi's session directory.
|
|
466
|
+
*
|
|
467
|
+
* Pure: `uuidExists(uuid) -> boolean`, `newUUID() -> string`, `now() -> Date`,
|
|
468
|
+
* and `newEntryId() -> string` are injected so tests can pin them.
|
|
469
|
+
*/
|
|
470
|
+
export function buildRevivePlan(doc, { cwd, sessionDir, uuidExists, newUUID, now = new Date(), newEntryId = makeEntryId }) {
|
|
471
|
+
const header = { ...doc.session.header, cwd };
|
|
472
|
+
const entries = [...doc.session.entries];
|
|
473
|
+
|
|
474
|
+
// Identity continuity across machines; fresh id at home where the original
|
|
475
|
+
// session file still exists (avoids ambiguous `pi --session <id>` matches).
|
|
476
|
+
const originalId = typeof doc.session.header?.id === "string" ? doc.session.header.id : undefined;
|
|
477
|
+
let sessionUUID = originalId && !uuidExists(originalId) ? originalId : newUUID();
|
|
478
|
+
|
|
479
|
+
// Name fallback: the name normally round-trips as a session_info entry in
|
|
480
|
+
// the tree; if it somehow didn't, re-attach it the same way pi itself does
|
|
481
|
+
// for /name. This runs AFTER checksum verification (see README: it is a
|
|
482
|
+
// declared revival transformation, not part of the lossless core).
|
|
483
|
+
let appendedName = null;
|
|
484
|
+
const hasNameEntry = entries.some((e) => e?.type === "session_info");
|
|
485
|
+
const name = typeof doc.session.name === "string" && doc.session.name.length > 0 ? doc.session.name : null;
|
|
486
|
+
if (name && !hasNameEntry) {
|
|
487
|
+
const lastEntryId = entries.length > 0 ? entries[entries.length - 1].id : null;
|
|
488
|
+
entries.push({
|
|
489
|
+
type: "session_info",
|
|
490
|
+
id: newEntryId(),
|
|
491
|
+
parentId: lastEntryId,
|
|
492
|
+
timestamp: now.toISOString(),
|
|
493
|
+
name,
|
|
494
|
+
});
|
|
495
|
+
appendedName = name;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const jsonl = buildSessionJsonl(header, entries);
|
|
499
|
+
const filePath = join(sessionDir, `${sessionFileTimestamp(now)}_${sessionUUID}.jsonl`);
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
filePath,
|
|
503
|
+
header,
|
|
504
|
+
sessionUUID,
|
|
505
|
+
appendedName,
|
|
506
|
+
entryCount: entries.length,
|
|
507
|
+
jsonl,
|
|
508
|
+
};
|
|
509
|
+
}
|