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.
@@ -0,0 +1,345 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { symlinkSync } from "node:fs";
7
+ import {
8
+ EXPORT_FORMAT,
9
+ SCHEMA_VERSION,
10
+ buildRevivePlan,
11
+ buildSessionJsonl,
12
+ collectTimingRecords,
13
+ collectUsage,
14
+ contextUsageSnapshot,
15
+ parseReviveArgs,
16
+ resolveExportPath,
17
+ sessionFileTimestamp,
18
+ sha256Hex,
19
+ summarizeTimings,
20
+ timestampedExportName,
21
+ validateExportDocument,
22
+ validateSessionCore,
23
+ writeExclusiveJson,
24
+ } from "../helpers.mjs";
25
+
26
+ // ─── Path resolution ─────────────────────────────────────────────────────────
27
+
28
+ test("resolves default, absolute, and quoted export destinations", () => {
29
+ const date = new Date("2026-04-13T12:34:56.789Z");
30
+ assert.equal(timestampedExportName(date), "pi-my-chat-export-20260413T123456-789Z.json");
31
+ // Revived session filenames use pi's own timestamp convention exactly.
32
+ assert.equal(sessionFileTimestamp(date), "2026-04-13T12-34-56-789Z");
33
+ assert.equal(
34
+ resolveExportPath("", "/work/project", date),
35
+ "/work/project/pi-my-chat-export-20260413T123456-789Z.json",
36
+ );
37
+ assert.equal(resolveExportPath('"/tmp/chat with spaces.json"', "/work", date), "/tmp/chat with spaces.json");
38
+ assert.equal(resolveExportPath("/tmp/sub/../chat.json", "/work", date), "/tmp/chat.json");
39
+ assert.throws(() => resolveExportPath("relative.json", "/work", date), /must be an absolute path/);
40
+ assert.throws(() => resolveExportPath("/tmp/chat.txt", "/work", date), /must have a \.json extension/);
41
+ assert.throws(() => resolveExportPath('"/tmp/chat.json', "/work", date), /unmatched quote/);
42
+ });
43
+
44
+ test("revive args: relative paths resolve, --force extracts anywhere, quotes unwrap", () => {
45
+ const r1 = parseReviveArgs("export.json", "/work");
46
+ assert.deepEqual(r1, { ok: true, force: false, path: "/work/export.json" });
47
+
48
+ const r2 = parseReviveArgs('/mnt/d/exports/chat with spaces.json --force', "/work");
49
+ assert.equal(r2.ok, true);
50
+ assert.equal(r2.force, true);
51
+ assert.equal(r2.path, "/mnt/d/exports/chat with spaces.json");
52
+
53
+ const r3 = parseReviveArgs("--force", "/work");
54
+ assert.equal(r3.ok, false);
55
+ assert.match(r3.error, /Provide the path/);
56
+
57
+ assert.equal(parseReviveArgs("", "/work").ok, false);
58
+ });
59
+
60
+ // ─── Usage accounting ────────────────────────────────────────────────────────
61
+
62
+ test("collects usage from all sources without double-counting timing entries", () => {
63
+ const usage = {
64
+ input: 10,
65
+ output: 4,
66
+ cacheRead: 3,
67
+ cacheWrite: 2,
68
+ totalTokens: 19,
69
+ cost: { input: 1, output: 2, cacheRead: 0.3, cacheWrite: 0.2, total: 3.5 },
70
+ };
71
+ const totals = collectUsage([
72
+ { type: "message", message: { role: "assistant", usage } },
73
+ { type: "message", message: { role: "toolResult", usage: { input: 1, output: 2, cost: { total: 0.5 } } } },
74
+ { type: "compaction", usage: { input: 5, output: 1, totalTokens: 6, cost: { input: 0.4, output: 0.1 } } },
75
+ { type: "branch_summary", usage: { input: 2, output: 0, cost: { total: 0.25 } } },
76
+ { type: "custom", customType: "operation-timing", data: { usage } }, // must be ignored
77
+ ]);
78
+ assert.deepEqual(totals, {
79
+ usageRecords: 4,
80
+ input: 18,
81
+ output: 7,
82
+ cacheRead: 3,
83
+ cacheWrite: 2,
84
+ totalTokens: 30,
85
+ cost: { input: 1.4, output: 2.1, cacheRead: 0.3, cacheWrite: 0.2, total: 4.75 },
86
+ });
87
+ });
88
+
89
+ test("usage guards clamp NaN, Infinity, and negative values", () => {
90
+ const totals = collectUsage([
91
+ { type: "message", message: { role: "assistant", usage: { input: Number.NaN, output: -5, totalTokens: Number.POSITIVE_INFINITY, cost: { total: Number.NaN } } } },
92
+ ]);
93
+ assert.equal(totals.input, 0);
94
+ assert.equal(totals.output, 0);
95
+ assert.equal(totals.totalTokens, 0);
96
+ assert.equal(totals.cost.total, 0);
97
+ });
98
+
99
+ // ─── Context snapshot ────────────────────────────────────────────────────────
100
+
101
+ test("context snapshot computes used/remaining/percent, preserves unknown tokens, and falls back to the snapshot's window", () => {
102
+ assert.deepEqual(contextUsageSnapshot({ tokens: 25000, contextWindow: 100000, percent: 25 }, 100000), {
103
+ used: 25000,
104
+ window: 100000,
105
+ remaining: 75000,
106
+ percentUsed: 25,
107
+ });
108
+ // Model window missing: the snapshot's own window is used instead.
109
+ assert.deepEqual(contextUsageSnapshot({ tokens: 25000, contextWindow: 100000, percent: 25 }, undefined), {
110
+ used: 25000,
111
+ window: 100000,
112
+ remaining: 75000,
113
+ percentUsed: 25,
114
+ });
115
+ // pi reports tokens === null right after compaction — recorded as null, never 0.
116
+ assert.deepEqual(contextUsageSnapshot({ tokens: null, contextWindow: 100000, percent: null }, 100000), {
117
+ used: null,
118
+ window: 100000,
119
+ remaining: null,
120
+ percentUsed: null,
121
+ });
122
+ assert.deepEqual(contextUsageSnapshot(undefined, 0), {
123
+ used: null,
124
+ window: 0,
125
+ remaining: null,
126
+ percentUsed: null,
127
+ });
128
+ // Negative or non-finite token counts clamp to 0.
129
+ assert.deepEqual(contextUsageSnapshot({ tokens: -5, contextWindow: 100, percent: null }, 100), {
130
+ used: 0,
131
+ window: 100,
132
+ remaining: 100,
133
+ percentUsed: 0,
134
+ });
135
+ });
136
+
137
+ // ─── Timings ─────────────────────────────────────────────────────────────────
138
+
139
+ test("extracts and summarizes timing records", () => {
140
+ const records = collectTimingRecords([
141
+ { type: "custom", customType: "operation-timing", data: { schemaVersion: 1, operationId: "a", kind: "model", startedAt: 1, endedAt: 11, durationMs: 10, status: "success" } },
142
+ { type: "custom", customType: "operation-timing", data: { schemaVersion: 1, operationId: "b", kind: "model", startedAt: 12, endedAt: 42, durationMs: 30, status: "error" } },
143
+ { type: "custom", customType: "operation-timing", data: { kind: "model" } }, // invalid: dropped
144
+ { type: "custom", customType: "other", data: {} },
145
+ ]);
146
+ assert.equal(records.length, 2);
147
+ assert.deepEqual(summarizeTimings(records).byKind.model, {
148
+ count: 2,
149
+ totalDurationMs: 40,
150
+ averageDurationMs: 20,
151
+ minDurationMs: 10,
152
+ maxDurationMs: 30,
153
+ statuses: { success: 1, error: 1 },
154
+ });
155
+ });
156
+
157
+ test("empty timing summaries behave cleanly", () => {
158
+ const summary = summarizeTimings([]);
159
+ assert.equal(summary.recordCount, 0);
160
+ assert.deepEqual(summary.byKind, {});
161
+ });
162
+
163
+ // ─── Exclusive private write ─────────────────────────────────────────────────
164
+
165
+ test("writes private JSON exclusively and rejects existing paths", async () => {
166
+ const root = await mkdtemp(join(tmpdir(), "pi-export-my-chat-"));
167
+ try {
168
+ await chmod(root, 0o700);
169
+ const destination = join(root, "chat.json");
170
+ const bytes = await writeExclusiveJson(destination, { ok: true });
171
+ assert.equal(bytes, Buffer.byteLength('{\n "ok": true\n}\n'));
172
+ assert.deepEqual(JSON.parse(await readFile(destination, "utf8")), { ok: true });
173
+ assert.equal((await lstat(destination)).mode & 0o777, 0o600);
174
+ await assert.rejects(() => writeExclusiveJson(destination, {}), /already exists/);
175
+
176
+ const directoryDestination = join(root, "directory.json");
177
+ await mkdir(directoryDestination);
178
+ await assert.rejects(() => writeExclusiveJson(directoryDestination, {}), /is a directory/);
179
+ await assert.rejects(() => writeExclusiveJson(join(root, "chat.txt"), {}), /must have a \.json extension/);
180
+ await assert.rejects(
181
+ () => writeExclusiveJson(join(root, "missing", "chat.json"), {}),
182
+ /parent directory does not exist/,
183
+ );
184
+
185
+ const symlinkDestination = join(root, "link.json");
186
+ await writeFile(join(root, "real.json"), "{}");
187
+ symlinkSync(join(root, "real.json"), symlinkDestination);
188
+ await assert.rejects(() => writeExclusiveJson(symlinkDestination, {}), /symbolic link/);
189
+ } finally {
190
+ await rm(root, { recursive: true, force: true });
191
+ }
192
+ });
193
+
194
+ // ─── Lossless core: JSONL, checksum, validation ───────────────────────────────
195
+
196
+ const header = { type: "session", version: 3, id: "11111111-2222-3333-4444-555555555555", timestamp: "2026-04-13T10:00:00.000Z", cwd: "/old/machine/project" };
197
+ const entries = [
198
+ { type: "model_change", id: "a1b2c3d4", parentId: null, timestamp: "2026-04-13T10:00:01.000Z", provider: "anthropic", modelId: "claude-sonnet-4-5" },
199
+ { type: "message", id: "b2c3d4e5", parentId: "a1b2c3d4", timestamp: "2026-04-13T10:00:02.000Z", message: { role: "user", content: "Hello" } },
200
+ { type: "message", id: "c3d4e5f6", parentId: "b2c3d4e5", timestamp: "2026-04-13T10:00:03.000Z", message: { role: "assistant", content: [{ type: "text", text: "Hi!" }], provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: 1776132003000 } },
201
+ ];
202
+
203
+ test("canonical JSONL: header line + one compact line per entry, trailing newline", () => {
204
+ const jsonl = buildSessionJsonl(header, entries);
205
+ const lines = jsonl.split("\n");
206
+ assert.equal(lines.length, entries.length + 2); // header + entries + trailing ""
207
+ assert.equal(lines[lines.length - 1], "");
208
+ assert.equal(lines[0], JSON.stringify(header));
209
+ assert.equal(lines[1], JSON.stringify(entries[0]));
210
+ // Deterministic: same inputs, same bytes, same checksum.
211
+ assert.equal(sha256Hex(jsonl), sha256Hex(buildSessionJsonl(header, entries)));
212
+ // Empty session: header only.
213
+ assert.equal(buildSessionJsonl(header, []), `${JSON.stringify(header)}\n`);
214
+ });
215
+
216
+ test("validateSessionCore: accepts a sound core and verifies checksum + leaf", () => {
217
+ const jsonl = buildSessionJsonl(header, entries);
218
+ const result = validateSessionCore(header, entries, {
219
+ expectedChecksum: sha256Hex(jsonl),
220
+ expectedLastEntryId: "c3d4e5f6",
221
+ });
222
+ assert.equal(result.ok, true);
223
+ assert.deepEqual(result.errors, []);
224
+ });
225
+
226
+ test("validateSessionCore: rejects duplicates, orphan parents, leaf mismatch, checksum mismatch", () => {
227
+ const bad = validateSessionCore(header, [entries[0], { ...entries[0] }], {});
228
+ assert.equal(bad.ok, false);
229
+ assert.match(bad.errors.join(" "), /Duplicate entry id/);
230
+
231
+ const orphan = validateSessionCore(header, [entries[0], { ...entries[1], parentId: "zzzzzzzz" }], {});
232
+ assert.match(orphan.errors.join(" "), /unknown parentId/);
233
+
234
+ const wrongLeaf = validateSessionCore(header, entries, { expectedLastEntryId: "a1b2c3d4" });
235
+ assert.match(wrongLeaf.errors.join(" "), /Leaf mismatch/);
236
+
237
+ const corrupt = validateSessionCore(header, entries, { expectedChecksum: sha256Hex("tampered") });
238
+ assert.match(corrupt.errors.join(" "), /Checksum mismatch/);
239
+ });
240
+
241
+ function makeExportDoc(overrides = {}) {
242
+ const jsonl = buildSessionJsonl(header, entries);
243
+ return {
244
+ format: EXPORT_FORMAT,
245
+ schemaVersion: SCHEMA_VERSION,
246
+ exportedAt: "2026-04-13T11:00:00.000Z",
247
+ session: {
248
+ name: "Refactor auth module",
249
+ cwd: "/old/machine/project",
250
+ header,
251
+ entries,
252
+ revive: {
253
+ checksum: sha256Hex(jsonl),
254
+ headerVersion: 3,
255
+ entryCount: entries.length,
256
+ lastEntryId: "c3d4e5f6",
257
+ },
258
+ },
259
+ ...overrides,
260
+ };
261
+ }
262
+
263
+ test("validateExportDocument: accepts a sound export", () => {
264
+ const result = validateExportDocument(makeExportDoc(), { maxHeaderVersion: 3 });
265
+ assert.equal(result.ok, true);
266
+ });
267
+
268
+ test("validateExportDocument: refuses wrong format, newer schema, newer header version, corruption", () => {
269
+ assert.match(
270
+ validateExportDocument({ format: "something-else" }, { maxHeaderVersion: 3 }).errors.join(" "),
271
+ /Not a pi-my-chat-export/,
272
+ );
273
+ assert.match(
274
+ validateExportDocument(makeExportDoc({ schemaVersion: 99 }), { maxHeaderVersion: 3 }).errors.join(" "),
275
+ /newer than this extension understands/,
276
+ );
277
+ assert.match(
278
+ validateExportDocument(makeExportDoc({ session: { ...makeExportDoc().session, header: { ...header, version: 4 } } }), { maxHeaderVersion: 3 }).errors.join(" "),
279
+ /newer than this pi supports/,
280
+ );
281
+ const tampered = makeExportDoc();
282
+ tampered.session.entries[1].message.content = "TAMPERED";
283
+ assert.match(
284
+ validateExportDocument(tampered, { maxHeaderVersion: 3 }).errors.join(" "),
285
+ /Checksum mismatch/,
286
+ );
287
+ const miscounted = makeExportDoc();
288
+ miscounted.session.revive.entryCount = entries.length + 1;
289
+ assert.match(
290
+ validateExportDocument(miscounted, { maxHeaderVersion: 3 }).errors.join(" "),
291
+ /Entry count mismatch/,
292
+ );
293
+ });
294
+
295
+ // ─── Revive plan ─────────────────────────────────────────────────────────────
296
+
297
+ test("revive plan: re-roots cwd, keeps UUID when absent, re-mints on collision, re-attaches lost names", () => {
298
+ const newSessionDir = "/home/user/.pi/agent/sessions/--home-user-project--";
299
+ const base = { cwd: "/home/user/project", sessionDir: newSessionDir, now: new Date("2026-04-14T09:00:00.000Z") };
300
+
301
+ // Fresh machine: original UUID kept, name already in entries (as a
302
+ // session_info entry) so no fallback append.
303
+ const withNameEntry = [...entries, { type: "session_info", id: "d4e5f6a7", parentId: "c3d4e5f6", timestamp: "2026-04-13T10:05:00.000Z", name: "Refactor auth module" }];
304
+ const docFresh = makeExportDoc();
305
+ docFresh.session.entries = withNameEntry;
306
+ docFresh.session.revive = {
307
+ checksum: sha256Hex(buildSessionJsonl(header, withNameEntry)),
308
+ headerVersion: 3,
309
+ entryCount: withNameEntry.length,
310
+ lastEntryId: "d4e5f6a7",
311
+ };
312
+
313
+ const planFresh = buildRevivePlan(docFresh, { ...base, uuidExists: () => false, newUUID: () => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" });
314
+ assert.equal(planFresh.sessionUUID, header.id); // identity continuity
315
+ assert.equal(planFresh.header.cwd, "/home/user/project"); // re-rooted
316
+ assert.equal(planFresh.appendedName, null); // name already travelled
317
+ assert.ok(planFresh.filePath.startsWith(newSessionDir));
318
+ assert.ok(planFresh.filePath.endsWith(`_${header.id}.jsonl`));
319
+ // The rebuilt JSONL parses back to exactly header + entries, in order.
320
+ const lines = planFresh.jsonl.trim().split("\n").map((line) => JSON.parse(line));
321
+ assert.equal(lines.length, withNameEntry.length + 1);
322
+ assert.deepEqual(lines[0], { ...header, cwd: "/home/user/project" });
323
+ assert.deepEqual(lines.at(-1), withNameEntry.at(-1));
324
+
325
+ // Same machine (UUID collision): fresh UUID minted.
326
+ const planCollide = buildRevivePlan(docFresh, { ...base, uuidExists: () => true, newUUID: () => "00000000-0000-0000-0000-000000000001" });
327
+ assert.equal(planCollide.sessionUUID, "00000000-0000-0000-0000-000000000001");
328
+
329
+ // Name lost from entries: re-attached as a trailing session_info entry.
330
+ const docNameless = makeExportDoc(); // entries lack session_info
331
+ const planName = buildRevivePlan(docNameless, { ...base, uuidExists: () => false, newUUID: () => "x", newEntryId: () => "feedface" });
332
+ assert.equal(planName.appendedName, "Refactor auth module");
333
+ const nameLine = JSON.parse(planName.jsonl.trim().split("\n").at(-1));
334
+ assert.equal(nameLine.type, "session_info");
335
+ assert.equal(nameLine.name, "Refactor auth module");
336
+ assert.equal(nameLine.id, "feedface");
337
+ assert.equal(nameLine.parentId, "c3d4e5f6");
338
+
339
+ // No name at all: nothing appended (pi default naming applies).
340
+ const docUnnamed = makeExportDoc();
341
+ docUnnamed.session.name = null;
342
+ const planUnnamed = buildRevivePlan(docUnnamed, { ...base, uuidExists: () => false, newUUID: () => "x", newEntryId: () => "feedface" });
343
+ assert.equal(planUnnamed.appendedName, null);
344
+ assert.equal(planUnnamed.entryCount, entries.length);
345
+ });