@savestate/cli 0.1.0 → 0.2.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/README.md +2 -12
- package/dist/adapters/chatgpt.d.ts +113 -0
- package/dist/adapters/chatgpt.d.ts.map +1 -0
- package/dist/adapters/chatgpt.js +668 -0
- package/dist/adapters/chatgpt.js.map +1 -0
- package/dist/adapters/claude-web.d.ts +81 -0
- package/dist/adapters/claude-web.d.ts.map +1 -0
- package/dist/adapters/claude-web.js +903 -0
- package/dist/adapters/claude-web.js.map +1 -0
- package/dist/adapters/gemini.d.ts +108 -0
- package/dist/adapters/gemini.d.ts.map +1 -0
- package/dist/adapters/gemini.js +814 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/openai-assistants.d.ts +79 -36
- package/dist/adapters/openai-assistants.d.ts.map +1 -1
- package/dist/adapters/openai-assistants.js +802 -78
- package/dist/adapters/openai-assistants.js.map +1 -1
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +6 -4
- package/dist/adapters/registry.js.map +1 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -10
- package/dist/cli.js.map +1 -1
- package/dist/commands/adapters.js +5 -5
- package/dist/commands/adapters.js.map +1 -1
- package/dist/commands/index.d.ts +0 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +0 -1
- package/dist/commands/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT Adapter
|
|
3
|
+
*
|
|
4
|
+
* Export-based adapter for ChatGPT data.
|
|
5
|
+
* Captures conversations, memories, custom instructions, and user profile
|
|
6
|
+
* from a ChatGPT data export (Settings → Data Controls → Export Data).
|
|
7
|
+
*
|
|
8
|
+
* Export structure:
|
|
9
|
+
* conversations.json — All conversations (tree-structured mapping)
|
|
10
|
+
* memories.json — ChatGPT memories (newer exports)
|
|
11
|
+
* custom_instructions.json — About user + response preferences
|
|
12
|
+
* user.json — User profile metadata
|
|
13
|
+
* model_comparisons.json — A/B testing data
|
|
14
|
+
* shared_conversations.json — Publicly shared conversations
|
|
15
|
+
* message_feedback.json — Thumbs up/down on messages
|
|
16
|
+
* chat.html — HTML render of conversations
|
|
17
|
+
*
|
|
18
|
+
* Detection:
|
|
19
|
+
* - SAVESTATE_CHATGPT_EXPORT env var → path to export directory or zip
|
|
20
|
+
* - chatgpt-export/ or conversations.json in cwd
|
|
21
|
+
* - .savestate/imports/chatgpt/ extracted export
|
|
22
|
+
*
|
|
23
|
+
* Restore is partial — ChatGPT has no public API for importing
|
|
24
|
+
* conversations. Generates chatgpt-restore-guide.md with data
|
|
25
|
+
* for manual re-entry.
|
|
26
|
+
*/
|
|
27
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import { join, basename } from 'node:path';
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { SAF_VERSION, generateSnapshotId } from '../format.js';
|
|
32
|
+
// ─── Constants ───────────────────────────────────────────────
|
|
33
|
+
const PROGRESS_INTERVAL = 500; // Log progress every N conversations
|
|
34
|
+
// ─── Adapter ─────────────────────────────────────────────────
|
|
35
|
+
export class ChatGPTAdapter {
|
|
36
|
+
id = 'chatgpt';
|
|
37
|
+
name = 'ChatGPT';
|
|
38
|
+
platform = 'chatgpt';
|
|
39
|
+
version = '0.1.0';
|
|
40
|
+
exportDir = '';
|
|
41
|
+
warnings = [];
|
|
42
|
+
async detect() {
|
|
43
|
+
const resolved = this.resolveExportPath();
|
|
44
|
+
return resolved !== null;
|
|
45
|
+
}
|
|
46
|
+
async extract() {
|
|
47
|
+
this.warnings = [];
|
|
48
|
+
const exportPath = this.resolveExportPath();
|
|
49
|
+
if (!exportPath) {
|
|
50
|
+
throw new Error('ChatGPT export not found.\n' +
|
|
51
|
+
'Provide the path via:\n' +
|
|
52
|
+
' • SAVESTATE_CHATGPT_EXPORT env var (path to export directory or zip)\n' +
|
|
53
|
+
' • Place chatgpt-export/ or conversations.json in the current directory\n' +
|
|
54
|
+
' • Extract export to .savestate/imports/chatgpt/\n\n' +
|
|
55
|
+
'Export your data from ChatGPT: Settings → Data Controls → Export Data');
|
|
56
|
+
}
|
|
57
|
+
this.exportDir = exportPath;
|
|
58
|
+
this.log(`Using ChatGPT export from: ${this.exportDir}`);
|
|
59
|
+
// 1. Parse user profile
|
|
60
|
+
this.log('Reading user profile...');
|
|
61
|
+
const userInfo = await this.readUserJson();
|
|
62
|
+
// 2. Parse custom instructions → identity.personality
|
|
63
|
+
this.log('Reading custom instructions...');
|
|
64
|
+
const { personality, instructionCount } = await this.readCustomInstructions();
|
|
65
|
+
// 3. Parse memories → memory.core
|
|
66
|
+
this.log('Reading memories...');
|
|
67
|
+
const memories = await this.readMemories();
|
|
68
|
+
// 4. Parse conversations → conversations + conversation details
|
|
69
|
+
this.log('Reading conversations...');
|
|
70
|
+
const { conversationMetas, conversationEntries } = await this.readConversations();
|
|
71
|
+
// Summary
|
|
72
|
+
this.log(`Found ${conversationMetas.length} conversations, ` +
|
|
73
|
+
`${memories.length} memories, ` +
|
|
74
|
+
`${instructionCount} custom instructions`);
|
|
75
|
+
// Build snapshot
|
|
76
|
+
const snapshotId = generateSnapshotId();
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
// Build config from user info
|
|
79
|
+
const config = {};
|
|
80
|
+
if (userInfo) {
|
|
81
|
+
config.user = userInfo;
|
|
82
|
+
}
|
|
83
|
+
// Store conversation data as memory entries so they survive the snapshot format
|
|
84
|
+
// (the conversations index only has metadata, not full messages)
|
|
85
|
+
const allMemory = [...memories, ...conversationEntries];
|
|
86
|
+
if (this.warnings.length > 0) {
|
|
87
|
+
for (const w of this.warnings) {
|
|
88
|
+
console.warn(` ⚠ ${w}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const snapshot = {
|
|
92
|
+
manifest: {
|
|
93
|
+
version: SAF_VERSION,
|
|
94
|
+
timestamp: now,
|
|
95
|
+
id: snapshotId,
|
|
96
|
+
platform: this.platform,
|
|
97
|
+
adapter: this.id,
|
|
98
|
+
checksum: '',
|
|
99
|
+
size: 0,
|
|
100
|
+
},
|
|
101
|
+
identity: {
|
|
102
|
+
personality: personality || undefined,
|
|
103
|
+
config: Object.keys(config).length > 0 ? config : undefined,
|
|
104
|
+
tools: [],
|
|
105
|
+
},
|
|
106
|
+
memory: {
|
|
107
|
+
core: allMemory,
|
|
108
|
+
knowledge: [],
|
|
109
|
+
},
|
|
110
|
+
conversations: {
|
|
111
|
+
total: conversationMetas.length,
|
|
112
|
+
conversations: conversationMetas,
|
|
113
|
+
},
|
|
114
|
+
platform: await this.identify(),
|
|
115
|
+
chain: {
|
|
116
|
+
current: snapshotId,
|
|
117
|
+
ancestors: [],
|
|
118
|
+
},
|
|
119
|
+
restoreHints: {
|
|
120
|
+
platform: this.platform,
|
|
121
|
+
steps: [
|
|
122
|
+
{
|
|
123
|
+
type: 'file',
|
|
124
|
+
description: 'Generate restore guide with memories and custom instructions',
|
|
125
|
+
target: 'chatgpt-restore-guide.md',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'manual',
|
|
129
|
+
description: 'Re-enter custom instructions in ChatGPT settings',
|
|
130
|
+
target: 'Settings → Personalization → Custom Instructions',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: 'manual',
|
|
134
|
+
description: 'Review and re-add memories via ChatGPT memory settings',
|
|
135
|
+
target: 'Settings → Personalization → Memory',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
manualSteps: [
|
|
139
|
+
'ChatGPT does not support importing conversations via API',
|
|
140
|
+
'Custom instructions must be manually re-entered in Settings → Personalization',
|
|
141
|
+
'Memories can be viewed but must be re-added manually or through conversation',
|
|
142
|
+
'Conversation history cannot be restored — it is preserved in the snapshot for reference',
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
this.log(`✓ Extraction complete`);
|
|
147
|
+
return snapshot;
|
|
148
|
+
}
|
|
149
|
+
async restore(snapshot) {
|
|
150
|
+
this.warnings = [];
|
|
151
|
+
const outputDir = process.cwd();
|
|
152
|
+
const guidePath = join(outputDir, 'chatgpt-restore-guide.md');
|
|
153
|
+
this.log('ChatGPT restore is partial — generating restore guide...');
|
|
154
|
+
const guide = this.buildRestoreGuide(snapshot);
|
|
155
|
+
await writeFile(guidePath, guide, 'utf-8');
|
|
156
|
+
// Also write memories to a separate file for easy reference
|
|
157
|
+
const memoriesFromCore = snapshot.memory.core.filter(m => m.source === 'chatgpt-memory');
|
|
158
|
+
if (memoriesFromCore.length > 0) {
|
|
159
|
+
const memoriesPath = join(outputDir, 'chatgpt-memories.md');
|
|
160
|
+
const memoriesDoc = this.buildMemoriesDoc(memoriesFromCore);
|
|
161
|
+
await writeFile(memoriesPath, memoriesDoc, 'utf-8');
|
|
162
|
+
this.log(` Wrote ${memoriesFromCore.length} memories to chatgpt-memories.md`);
|
|
163
|
+
}
|
|
164
|
+
// Write custom instructions if present
|
|
165
|
+
if (snapshot.identity.personality) {
|
|
166
|
+
const instructionsPath = join(outputDir, 'chatgpt-custom-instructions.md');
|
|
167
|
+
const instructionsDoc = this.buildInstructionsDoc(snapshot.identity.personality);
|
|
168
|
+
await writeFile(instructionsPath, instructionsDoc, 'utf-8');
|
|
169
|
+
this.log(' Wrote custom instructions to chatgpt-custom-instructions.md');
|
|
170
|
+
}
|
|
171
|
+
console.error('');
|
|
172
|
+
console.error('┌─────────────────────────────────────────────────────┐');
|
|
173
|
+
console.error('│ ⚠ ChatGPT restore is partial │');
|
|
174
|
+
console.error('├─────────────────────────────────────────────────────┤');
|
|
175
|
+
console.error('│ See chatgpt-restore-guide.md for instructions. │');
|
|
176
|
+
console.error('│ Conversations are preserved but cannot be imported. │');
|
|
177
|
+
console.error('│ Memories and custom instructions require manual │');
|
|
178
|
+
console.error('│ re-entry in ChatGPT settings. │');
|
|
179
|
+
console.error('└─────────────────────────────────────────────────────┘');
|
|
180
|
+
if (this.warnings.length > 0) {
|
|
181
|
+
console.error('');
|
|
182
|
+
for (const w of this.warnings) {
|
|
183
|
+
console.warn(` ⚠ ${w}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async identify() {
|
|
188
|
+
return {
|
|
189
|
+
name: 'ChatGPT',
|
|
190
|
+
version: this.version,
|
|
191
|
+
exportMethod: 'data-export',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// ─── Private: Export Path Resolution ───────────────────────
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the export directory path from env var, cwd, or .savestate/imports/.
|
|
197
|
+
* Returns null if no valid export is found.
|
|
198
|
+
*/
|
|
199
|
+
resolveExportPath() {
|
|
200
|
+
// 1. SAVESTATE_CHATGPT_EXPORT env var
|
|
201
|
+
const envPath = process.env.SAVESTATE_CHATGPT_EXPORT;
|
|
202
|
+
if (envPath) {
|
|
203
|
+
// If it ends with .zip, we don't support direct zip reading (yet).
|
|
204
|
+
// Require pre-extracted.
|
|
205
|
+
if (envPath.endsWith('.zip')) {
|
|
206
|
+
// Check if there's an extracted directory next to the zip
|
|
207
|
+
const extractedDir = envPath.replace(/\.zip$/, '');
|
|
208
|
+
if (existsSync(extractedDir) && this.hasExportFiles(extractedDir)) {
|
|
209
|
+
return extractedDir;
|
|
210
|
+
}
|
|
211
|
+
// Check if the zip name suggests a directory exists
|
|
212
|
+
const parentDir = join(extractedDir, '..');
|
|
213
|
+
const possibleDirs = ['chatgpt-export', basename(extractedDir)];
|
|
214
|
+
for (const dir of possibleDirs) {
|
|
215
|
+
const candidate = join(parentDir, dir);
|
|
216
|
+
if (existsSync(candidate) && this.hasExportFiles(candidate)) {
|
|
217
|
+
return candidate;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null; // Zip not yet extracted
|
|
221
|
+
}
|
|
222
|
+
if (existsSync(envPath) && this.hasExportFiles(envPath)) {
|
|
223
|
+
return envPath;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
// 2. chatgpt-export/ in current directory
|
|
228
|
+
const chatgptExportDir = join(process.cwd(), 'chatgpt-export');
|
|
229
|
+
if (existsSync(chatgptExportDir) && this.hasExportFiles(chatgptExportDir)) {
|
|
230
|
+
return chatgptExportDir;
|
|
231
|
+
}
|
|
232
|
+
// 3. conversations.json directly in current directory
|
|
233
|
+
const conversationsJson = join(process.cwd(), 'conversations.json');
|
|
234
|
+
if (existsSync(conversationsJson)) {
|
|
235
|
+
return process.cwd();
|
|
236
|
+
}
|
|
237
|
+
// 4. .savestate/imports/chatgpt/
|
|
238
|
+
const savestateImports = join(process.cwd(), '.savestate', 'imports', 'chatgpt');
|
|
239
|
+
if (existsSync(savestateImports) && this.hasExportFiles(savestateImports)) {
|
|
240
|
+
return savestateImports;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Check if a directory contains ChatGPT export files.
|
|
246
|
+
*/
|
|
247
|
+
hasExportFiles(dir) {
|
|
248
|
+
// Must have at least conversations.json
|
|
249
|
+
return existsSync(join(dir, 'conversations.json'));
|
|
250
|
+
}
|
|
251
|
+
// ─── Private: File Parsers ─────────────────────────────────
|
|
252
|
+
/**
|
|
253
|
+
* Safely read and parse a JSON file from the export directory.
|
|
254
|
+
*/
|
|
255
|
+
async readJsonFile(filename) {
|
|
256
|
+
const filePath = join(this.exportDir, filename);
|
|
257
|
+
if (!existsSync(filePath))
|
|
258
|
+
return null;
|
|
259
|
+
try {
|
|
260
|
+
const content = await readFile(filePath, 'utf-8');
|
|
261
|
+
return JSON.parse(content);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
this.warn(`Failed to parse ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Read user.json for profile metadata.
|
|
270
|
+
*/
|
|
271
|
+
async readUserJson() {
|
|
272
|
+
const data = await this.readJsonFile('user.json');
|
|
273
|
+
if (!data)
|
|
274
|
+
return null;
|
|
275
|
+
// Normalize nested structure
|
|
276
|
+
const user = data.user ?? data;
|
|
277
|
+
return {
|
|
278
|
+
id: user.id ?? data.id,
|
|
279
|
+
email: user.email ?? data.email,
|
|
280
|
+
name: user.name ?? data.name,
|
|
281
|
+
chatgpt_plus_user: data.chatgpt_plus_user,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Read custom_instructions.json and build personality string.
|
|
286
|
+
*/
|
|
287
|
+
async readCustomInstructions() {
|
|
288
|
+
const data = await this.readJsonFile('custom_instructions.json');
|
|
289
|
+
if (!data)
|
|
290
|
+
return { personality: '', instructionCount: 0 };
|
|
291
|
+
const parts = [];
|
|
292
|
+
let count = 0;
|
|
293
|
+
// Handle both old and new format keys
|
|
294
|
+
const aboutUser = data.about_user_message ?? data.about_user;
|
|
295
|
+
const responsePrefs = data.about_model_message ?? data.response_preferences;
|
|
296
|
+
if (aboutUser) {
|
|
297
|
+
parts.push(`--- About User (Custom Instructions) ---\n${aboutUser}`);
|
|
298
|
+
count++;
|
|
299
|
+
}
|
|
300
|
+
if (responsePrefs) {
|
|
301
|
+
parts.push(`--- Response Preferences (Custom Instructions) ---\n${responsePrefs}`);
|
|
302
|
+
count++;
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
personality: parts.join('\n\n'),
|
|
306
|
+
instructionCount: count,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Read memories.json into MemoryEntry array.
|
|
311
|
+
*/
|
|
312
|
+
async readMemories() {
|
|
313
|
+
const data = await this.readJsonFile('memories.json');
|
|
314
|
+
if (!data || !Array.isArray(data))
|
|
315
|
+
return [];
|
|
316
|
+
return data.map(mem => {
|
|
317
|
+
const createdAt = mem.created_at
|
|
318
|
+
?? (mem.create_time ? new Date(mem.create_time * 1000).toISOString() : new Date().toISOString());
|
|
319
|
+
const updatedAt = mem.updated_at
|
|
320
|
+
?? (mem.update_time ? new Date(mem.update_time * 1000).toISOString() : undefined);
|
|
321
|
+
return {
|
|
322
|
+
id: `chatgpt-memory:${mem.id}`,
|
|
323
|
+
content: mem.content,
|
|
324
|
+
source: 'chatgpt-memory',
|
|
325
|
+
createdAt,
|
|
326
|
+
updatedAt,
|
|
327
|
+
metadata: { platform: 'chatgpt' },
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Read conversations.json, walk tree structures, and produce metadata + entries.
|
|
333
|
+
*/
|
|
334
|
+
async readConversations() {
|
|
335
|
+
const data = await this.readJsonFile('conversations.json');
|
|
336
|
+
if (!data || !Array.isArray(data))
|
|
337
|
+
return { conversationMetas: [], conversationEntries: [] };
|
|
338
|
+
const conversationMetas = [];
|
|
339
|
+
const conversationEntries = [];
|
|
340
|
+
const total = data.length;
|
|
341
|
+
const isLarge = total >= 1000;
|
|
342
|
+
if (isLarge) {
|
|
343
|
+
this.log(` Processing ${total} conversations (this may take a moment)...`);
|
|
344
|
+
}
|
|
345
|
+
for (let i = 0; i < data.length; i++) {
|
|
346
|
+
const conv = data[i];
|
|
347
|
+
// Progress logging for large exports
|
|
348
|
+
if (isLarge && i > 0 && i % PROGRESS_INTERVAL === 0) {
|
|
349
|
+
this.log(` Progress: ${i}/${total} conversations processed...`);
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const messages = this.walkConversationTree(conv);
|
|
353
|
+
const convId = conv.conversation_id ?? conv.id ?? this.hashString(conv.title + conv.create_time);
|
|
354
|
+
const createdAt = conv.create_time
|
|
355
|
+
? new Date(conv.create_time * 1000).toISOString()
|
|
356
|
+
: new Date().toISOString();
|
|
357
|
+
const updatedAt = conv.update_time
|
|
358
|
+
? new Date(conv.update_time * 1000).toISOString()
|
|
359
|
+
: createdAt;
|
|
360
|
+
conversationMetas.push({
|
|
361
|
+
id: convId,
|
|
362
|
+
title: conv.title || undefined,
|
|
363
|
+
createdAt,
|
|
364
|
+
updatedAt,
|
|
365
|
+
messageCount: messages.length,
|
|
366
|
+
path: `conversations/${convId}.json`,
|
|
367
|
+
});
|
|
368
|
+
// Store conversation messages as a memory entry for snapshot persistence
|
|
369
|
+
const conversation = {
|
|
370
|
+
id: convId,
|
|
371
|
+
title: conv.title || undefined,
|
|
372
|
+
createdAt,
|
|
373
|
+
updatedAt,
|
|
374
|
+
messages,
|
|
375
|
+
metadata: {
|
|
376
|
+
default_model: conv.default_model_slug,
|
|
377
|
+
is_archived: conv.is_archived,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
conversationEntries.push({
|
|
381
|
+
id: `conversation:${convId}`,
|
|
382
|
+
content: JSON.stringify(conversation),
|
|
383
|
+
source: 'chatgpt-conversation',
|
|
384
|
+
createdAt,
|
|
385
|
+
updatedAt,
|
|
386
|
+
metadata: {
|
|
387
|
+
title: conv.title,
|
|
388
|
+
messageCount: messages.length,
|
|
389
|
+
model: conv.default_model_slug,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
this.warn(`Failed to parse conversation "${conv.title ?? 'unknown'}": ` +
|
|
395
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (isLarge) {
|
|
399
|
+
this.log(` Processed all ${total} conversations.`);
|
|
400
|
+
}
|
|
401
|
+
return { conversationMetas, conversationEntries };
|
|
402
|
+
}
|
|
403
|
+
// ─── Private: Tree Walking ─────────────────────────────────
|
|
404
|
+
/**
|
|
405
|
+
* Walk the conversation tree (mapping) and produce a linear list of messages.
|
|
406
|
+
*
|
|
407
|
+
* Algorithm:
|
|
408
|
+
* 1. Find root node (parent is null or missing)
|
|
409
|
+
* 2. Walk children depth-first
|
|
410
|
+
* 3. When a node has multiple children (branching = user edited), take the last branch
|
|
411
|
+
* 4. Skip null/system messages without content
|
|
412
|
+
* 5. Collect messages in order
|
|
413
|
+
*/
|
|
414
|
+
walkConversationTree(conv) {
|
|
415
|
+
const mapping = conv.mapping;
|
|
416
|
+
if (!mapping || Object.keys(mapping).length === 0)
|
|
417
|
+
return [];
|
|
418
|
+
// Find root node(s) — node with no parent or parent not in mapping
|
|
419
|
+
const rootNodes = Object.values(mapping).filter(node => !node.parent || !mapping[node.parent]);
|
|
420
|
+
if (rootNodes.length === 0)
|
|
421
|
+
return [];
|
|
422
|
+
// Start from the first root (there should usually be exactly one)
|
|
423
|
+
const root = rootNodes[0];
|
|
424
|
+
const messages = [];
|
|
425
|
+
this.collectMessages(root, mapping, messages);
|
|
426
|
+
return messages;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Recursively collect messages by walking the tree depth-first.
|
|
430
|
+
* For branches (multiple children), take the last child (most recent edit/branch).
|
|
431
|
+
*/
|
|
432
|
+
collectMessages(node, mapping, messages) {
|
|
433
|
+
// Extract message from this node if it exists and has content
|
|
434
|
+
if (node.message) {
|
|
435
|
+
const msg = this.extractMessage(node.message);
|
|
436
|
+
if (msg) {
|
|
437
|
+
messages.push(msg);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Walk children — take the last child when branching
|
|
441
|
+
if (node.children.length > 0) {
|
|
442
|
+
// Last child = most recent branch (user edited and continued from latest)
|
|
443
|
+
const nextChildId = node.children[node.children.length - 1];
|
|
444
|
+
const nextChild = mapping[nextChildId];
|
|
445
|
+
if (nextChild) {
|
|
446
|
+
this.collectMessages(nextChild, mapping, messages);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Extract a Message from a ChatGPT message node.
|
|
452
|
+
* Returns null if the message should be skipped (system, empty, etc.)
|
|
453
|
+
*/
|
|
454
|
+
extractMessage(msg) {
|
|
455
|
+
const role = msg.author.role;
|
|
456
|
+
// Skip system messages and empty messages
|
|
457
|
+
if (role === 'system')
|
|
458
|
+
return null;
|
|
459
|
+
// Extract text content
|
|
460
|
+
const text = this.extractContent(msg.content);
|
|
461
|
+
if (!text)
|
|
462
|
+
return null;
|
|
463
|
+
// Map roles: user, assistant, tool
|
|
464
|
+
let mappedRole;
|
|
465
|
+
if (role === 'user') {
|
|
466
|
+
mappedRole = 'user';
|
|
467
|
+
}
|
|
468
|
+
else if (role === 'assistant') {
|
|
469
|
+
mappedRole = 'assistant';
|
|
470
|
+
}
|
|
471
|
+
else if (role === 'tool') {
|
|
472
|
+
mappedRole = 'tool';
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
// Unknown role — treat as system and skip
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const timestamp = msg.create_time
|
|
479
|
+
? new Date(msg.create_time * 1000).toISOString()
|
|
480
|
+
: new Date().toISOString();
|
|
481
|
+
return {
|
|
482
|
+
id: msg.id,
|
|
483
|
+
role: mappedRole,
|
|
484
|
+
content: text,
|
|
485
|
+
timestamp,
|
|
486
|
+
metadata: {
|
|
487
|
+
...(msg.metadata?.model_slug ? { model: msg.metadata.model_slug } : {}),
|
|
488
|
+
...(msg.author.name ? { author_name: msg.author.name } : {}),
|
|
489
|
+
...(msg.recipient && msg.recipient !== 'all' ? { recipient: msg.recipient } : {}),
|
|
490
|
+
...(msg.status ? { status: msg.status } : {}),
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Extract text from ChatGPT content structure.
|
|
496
|
+
* Handles content_type: "text", "code", "tether_browsing_display", etc.
|
|
497
|
+
*/
|
|
498
|
+
extractContent(content) {
|
|
499
|
+
if (!content)
|
|
500
|
+
return null;
|
|
501
|
+
// Direct text field
|
|
502
|
+
if (content.text)
|
|
503
|
+
return content.text;
|
|
504
|
+
// Result field (for tool outputs)
|
|
505
|
+
if (content.result)
|
|
506
|
+
return content.result;
|
|
507
|
+
// Parts array
|
|
508
|
+
if (content.parts && Array.isArray(content.parts)) {
|
|
509
|
+
const textParts = [];
|
|
510
|
+
for (const part of content.parts) {
|
|
511
|
+
if (typeof part === 'string') {
|
|
512
|
+
if (part.trim())
|
|
513
|
+
textParts.push(part);
|
|
514
|
+
}
|
|
515
|
+
else if (part && typeof part === 'object') {
|
|
516
|
+
// Image, file, or other structured content
|
|
517
|
+
if ('text' in part && typeof part.text === 'string') {
|
|
518
|
+
textParts.push(part.text);
|
|
519
|
+
}
|
|
520
|
+
else if ('asset_pointer' in part) {
|
|
521
|
+
textParts.push('[Image]');
|
|
522
|
+
}
|
|
523
|
+
else if ('content_type' in part && part.content_type === 'image_asset_pointer') {
|
|
524
|
+
textParts.push('[Image]');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const joined = textParts.join('\n');
|
|
529
|
+
return joined || null;
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
// ─── Private: Restore Guide Generation ─────────────────────
|
|
534
|
+
/**
|
|
535
|
+
* Build the chatgpt-restore-guide.md content.
|
|
536
|
+
*/
|
|
537
|
+
buildRestoreGuide(snapshot) {
|
|
538
|
+
const lines = [];
|
|
539
|
+
lines.push('# ChatGPT Restore Guide');
|
|
540
|
+
lines.push('');
|
|
541
|
+
lines.push(`Generated by SaveState on ${new Date().toISOString()}`);
|
|
542
|
+
lines.push(`Snapshot ID: ${snapshot.manifest.id}`);
|
|
543
|
+
lines.push('');
|
|
544
|
+
lines.push('---');
|
|
545
|
+
lines.push('');
|
|
546
|
+
lines.push('## ⚠️ Important');
|
|
547
|
+
lines.push('');
|
|
548
|
+
lines.push('ChatGPT does not support importing conversations or memories via API.');
|
|
549
|
+
lines.push('This guide contains your data for manual re-entry.');
|
|
550
|
+
lines.push('');
|
|
551
|
+
// Custom Instructions
|
|
552
|
+
if (snapshot.identity.personality) {
|
|
553
|
+
lines.push('## Custom Instructions');
|
|
554
|
+
lines.push('');
|
|
555
|
+
lines.push('Go to **ChatGPT → Settings → Personalization → Custom Instructions**');
|
|
556
|
+
lines.push('and copy the following:');
|
|
557
|
+
lines.push('');
|
|
558
|
+
lines.push('```');
|
|
559
|
+
lines.push(snapshot.identity.personality);
|
|
560
|
+
lines.push('```');
|
|
561
|
+
lines.push('');
|
|
562
|
+
}
|
|
563
|
+
// Memories
|
|
564
|
+
const memories = snapshot.memory.core.filter(m => m.source === 'chatgpt-memory');
|
|
565
|
+
if (memories.length > 0) {
|
|
566
|
+
lines.push('## Memories');
|
|
567
|
+
lines.push('');
|
|
568
|
+
lines.push(`You had ${memories.length} memories. To re-create them, you can:`);
|
|
569
|
+
lines.push('1. Mention these facts in conversations and ChatGPT will learn them');
|
|
570
|
+
lines.push('2. Go to **Settings → Personalization → Memory → Manage** to review');
|
|
571
|
+
lines.push('');
|
|
572
|
+
for (const mem of memories) {
|
|
573
|
+
lines.push(`- ${mem.content}`);
|
|
574
|
+
}
|
|
575
|
+
lines.push('');
|
|
576
|
+
}
|
|
577
|
+
// Conversation summary
|
|
578
|
+
if (snapshot.conversations.total > 0) {
|
|
579
|
+
lines.push('## Conversations');
|
|
580
|
+
lines.push('');
|
|
581
|
+
lines.push(`Your export contained ${snapshot.conversations.total} conversations.`);
|
|
582
|
+
lines.push('These are preserved in the snapshot but cannot be imported into ChatGPT.');
|
|
583
|
+
lines.push('Use `savestate inspect` to browse them.');
|
|
584
|
+
lines.push('');
|
|
585
|
+
// Show a few recent ones
|
|
586
|
+
const recent = snapshot.conversations.conversations
|
|
587
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
588
|
+
.slice(0, 20);
|
|
589
|
+
if (recent.length > 0) {
|
|
590
|
+
lines.push('### Recent Conversations');
|
|
591
|
+
lines.push('');
|
|
592
|
+
lines.push('| Date | Title | Messages |');
|
|
593
|
+
lines.push('|------|-------|----------|');
|
|
594
|
+
for (const conv of recent) {
|
|
595
|
+
const date = conv.updatedAt.slice(0, 10);
|
|
596
|
+
const title = conv.title ?? '(untitled)';
|
|
597
|
+
lines.push(`| ${date} | ${title} | ${conv.messageCount} |`);
|
|
598
|
+
}
|
|
599
|
+
lines.push('');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Platform info
|
|
603
|
+
const config = snapshot.identity.config;
|
|
604
|
+
if (config?.user) {
|
|
605
|
+
const user = config.user;
|
|
606
|
+
lines.push('## Account Info');
|
|
607
|
+
lines.push('');
|
|
608
|
+
if (user.email)
|
|
609
|
+
lines.push(`- Email: ${user.email}`);
|
|
610
|
+
if (user.name)
|
|
611
|
+
lines.push(`- Name: ${user.name}`);
|
|
612
|
+
if (user.chatgpt_plus_user)
|
|
613
|
+
lines.push('- Plan: ChatGPT Plus');
|
|
614
|
+
lines.push('');
|
|
615
|
+
}
|
|
616
|
+
return lines.join('\n');
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Build a standalone memories document.
|
|
620
|
+
*/
|
|
621
|
+
buildMemoriesDoc(memories) {
|
|
622
|
+
const lines = [];
|
|
623
|
+
lines.push('# ChatGPT Memories');
|
|
624
|
+
lines.push('');
|
|
625
|
+
lines.push(`Exported ${memories.length} memories.`);
|
|
626
|
+
lines.push('');
|
|
627
|
+
lines.push('To restore these, mention them in conversations with ChatGPT');
|
|
628
|
+
lines.push('or manually add them in Settings → Personalization → Memory.');
|
|
629
|
+
lines.push('');
|
|
630
|
+
lines.push('---');
|
|
631
|
+
lines.push('');
|
|
632
|
+
for (const mem of memories) {
|
|
633
|
+
const date = mem.createdAt.slice(0, 10);
|
|
634
|
+
lines.push(`- **[${date}]** ${mem.content}`);
|
|
635
|
+
}
|
|
636
|
+
lines.push('');
|
|
637
|
+
return lines.join('\n');
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Build a standalone custom instructions document.
|
|
641
|
+
*/
|
|
642
|
+
buildInstructionsDoc(personality) {
|
|
643
|
+
const lines = [];
|
|
644
|
+
lines.push('# ChatGPT Custom Instructions');
|
|
645
|
+
lines.push('');
|
|
646
|
+
lines.push('Copy these into ChatGPT → Settings → Personalization → Custom Instructions.');
|
|
647
|
+
lines.push('');
|
|
648
|
+
lines.push('---');
|
|
649
|
+
lines.push('');
|
|
650
|
+
lines.push(personality);
|
|
651
|
+
lines.push('');
|
|
652
|
+
return lines.join('\n');
|
|
653
|
+
}
|
|
654
|
+
// ─── Private: Utilities ────────────────────────────────────
|
|
655
|
+
log(msg) {
|
|
656
|
+
console.error(msg);
|
|
657
|
+
}
|
|
658
|
+
warn(msg) {
|
|
659
|
+
this.warnings.push(msg);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Simple string hash for generating IDs when conversation_id is missing.
|
|
663
|
+
*/
|
|
664
|
+
hashString(input) {
|
|
665
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
//# sourceMappingURL=chatgpt.js.map
|