@savestate/cli 0.7.0 → 0.8.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.
Files changed (89) hide show
  1. package/dist/cli/__tests__/progress.test.d.ts +5 -0
  2. package/dist/cli/__tests__/progress.test.d.ts.map +1 -0
  3. package/dist/cli/__tests__/progress.test.js +212 -0
  4. package/dist/cli/__tests__/progress.test.js.map +1 -0
  5. package/dist/cli/__tests__/signal-handler.test.d.ts +5 -0
  6. package/dist/cli/__tests__/signal-handler.test.d.ts.map +1 -0
  7. package/dist/cli/__tests__/signal-handler.test.js +99 -0
  8. package/dist/cli/__tests__/signal-handler.test.js.map +1 -0
  9. package/dist/cli/__tests__/summary.test.d.ts +5 -0
  10. package/dist/cli/__tests__/summary.test.d.ts.map +1 -0
  11. package/dist/cli/__tests__/summary.test.js +242 -0
  12. package/dist/cli/__tests__/summary.test.js.map +1 -0
  13. package/dist/cli/index.d.ts +10 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +10 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/progress.d.ts +86 -0
  18. package/dist/cli/progress.d.ts.map +1 -0
  19. package/dist/cli/progress.js +205 -0
  20. package/dist/cli/progress.js.map +1 -0
  21. package/dist/cli/prompts.d.ts +49 -0
  22. package/dist/cli/prompts.d.ts.map +1 -0
  23. package/dist/cli/prompts.js +266 -0
  24. package/dist/cli/prompts.js.map +1 -0
  25. package/dist/cli/signal-handler.d.ts +63 -0
  26. package/dist/cli/signal-handler.d.ts.map +1 -0
  27. package/dist/cli/signal-handler.js +165 -0
  28. package/dist/cli/signal-handler.js.map +1 -0
  29. package/dist/cli/summary.d.ts +33 -0
  30. package/dist/cli/summary.d.ts.map +1 -0
  31. package/dist/cli/summary.js +296 -0
  32. package/dist/cli/summary.js.map +1 -0
  33. package/dist/cli.js +7 -1
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/migrate.d.ts +18 -4
  36. package/dist/commands/migrate.d.ts.map +1 -1
  37. package/dist/commands/migrate.js +324 -163
  38. package/dist/commands/migrate.js.map +1 -1
  39. package/dist/migrate/__tests__/capabilities.test.d.ts +7 -0
  40. package/dist/migrate/__tests__/capabilities.test.d.ts.map +1 -0
  41. package/dist/migrate/__tests__/capabilities.test.js +90 -0
  42. package/dist/migrate/__tests__/capabilities.test.js.map +1 -0
  43. package/dist/migrate/__tests__/chatgpt-loader.test.d.ts +7 -0
  44. package/dist/migrate/__tests__/chatgpt-loader.test.d.ts.map +1 -0
  45. package/dist/migrate/__tests__/chatgpt-loader.test.js +889 -0
  46. package/dist/migrate/__tests__/chatgpt-loader.test.js.map +1 -0
  47. package/dist/migrate/__tests__/edge-cases.test.d.ts +7 -0
  48. package/dist/migrate/__tests__/edge-cases.test.d.ts.map +1 -0
  49. package/dist/migrate/__tests__/edge-cases.test.js +787 -0
  50. package/dist/migrate/__tests__/edge-cases.test.js.map +1 -0
  51. package/dist/migrate/__tests__/error-recovery.test.d.ts +7 -0
  52. package/dist/migrate/__tests__/error-recovery.test.d.ts.map +1 -0
  53. package/dist/migrate/__tests__/error-recovery.test.js +461 -0
  54. package/dist/migrate/__tests__/error-recovery.test.js.map +1 -0
  55. package/dist/migrate/__tests__/integration.test.d.ts +7 -0
  56. package/dist/migrate/__tests__/integration.test.d.ts.map +1 -0
  57. package/dist/migrate/__tests__/integration.test.js +536 -0
  58. package/dist/migrate/__tests__/integration.test.js.map +1 -0
  59. package/dist/migrate/__tests__/performance.test.d.ts +7 -0
  60. package/dist/migrate/__tests__/performance.test.d.ts.map +1 -0
  61. package/dist/migrate/__tests__/performance.test.js +478 -0
  62. package/dist/migrate/__tests__/performance.test.js.map +1 -0
  63. package/dist/migrate/__tests__/registry.test.d.ts +7 -0
  64. package/dist/migrate/__tests__/registry.test.d.ts.map +1 -0
  65. package/dist/migrate/__tests__/registry.test.js +167 -0
  66. package/dist/migrate/__tests__/registry.test.js.map +1 -0
  67. package/dist/migrate/compatibility.d.ts +47 -0
  68. package/dist/migrate/compatibility.d.ts.map +1 -0
  69. package/dist/migrate/compatibility.js +468 -0
  70. package/dist/migrate/compatibility.js.map +1 -0
  71. package/dist/migrate/extractors/__tests__/claude.test.d.ts +12 -0
  72. package/dist/migrate/extractors/__tests__/claude.test.d.ts.map +1 -0
  73. package/dist/migrate/extractors/__tests__/claude.test.js +789 -0
  74. package/dist/migrate/extractors/__tests__/claude.test.js.map +1 -0
  75. package/dist/migrate/extractors/claude.d.ts +69 -0
  76. package/dist/migrate/extractors/claude.d.ts.map +1 -0
  77. package/dist/migrate/extractors/claude.js +1136 -0
  78. package/dist/migrate/extractors/claude.js.map +1 -0
  79. package/dist/migrate/extractors/registry.js +3 -2
  80. package/dist/migrate/extractors/registry.js.map +1 -1
  81. package/dist/migrate/loaders/chatgpt.d.ts +72 -0
  82. package/dist/migrate/loaders/chatgpt.d.ts.map +1 -0
  83. package/dist/migrate/loaders/chatgpt.js +691 -0
  84. package/dist/migrate/loaders/chatgpt.js.map +1 -0
  85. package/dist/migrate/loaders/registry.js +3 -2
  86. package/dist/migrate/loaders/registry.js.map +1 -1
  87. package/dist/migrate/types.d.ts +2 -0
  88. package/dist/migrate/types.d.ts.map +1 -1
  89. package/package.json +2 -1
@@ -0,0 +1,691 @@
1
+ /**
2
+ * ChatGPT Loader
3
+ *
4
+ * Loads migration data into ChatGPT.
5
+ *
6
+ * Target Structure:
7
+ * - Custom Instructions (from transformed system prompt, max 1500 chars)
8
+ * - Memories (from project knowledge, max 100 entries)
9
+ * - Uploaded Files (via Files API)
10
+ *
11
+ * NOTE: ChatGPT has limited public API support. Some operations require manual steps:
12
+ * - Custom Instructions: Not settable via API, loader provides guidance
13
+ * - Memories: Not settable via API, loader provides formatted content to add manually
14
+ * - Files: Can be uploaded via OpenAI Files API for use with Assistants
15
+ * - Custom GPTs: Require manual creation through ChatGPT interface
16
+ *
17
+ * @see https://platform.openai.com/docs/api-reference/files
18
+ */
19
+ import { readFile } from 'node:fs/promises';
20
+ import { existsSync } from 'node:fs';
21
+ import { basename, join, resolve, relative } from 'node:path';
22
+ import { mkdir, writeFile } from 'node:fs/promises';
23
+ import { getPlatformCapabilities } from '../capabilities.js';
24
+ import { intelligentTruncate } from '../transformers/rules.js';
25
+ // ─── Rate Limiter ────────────────────────────────────────────
26
+ class RateLimiter {
27
+ requestTimes = [];
28
+ maxRequests;
29
+ windowMs;
30
+ constructor(maxRequests = 60, windowMs = 60_000) {
31
+ this.maxRequests = maxRequests;
32
+ this.windowMs = windowMs;
33
+ }
34
+ async acquire() {
35
+ const now = Date.now();
36
+ this.requestTimes = this.requestTimes.filter((t) => now - t < this.windowMs);
37
+ if (this.requestTimes.length >= this.maxRequests) {
38
+ const oldestRequest = this.requestTimes[0];
39
+ const waitTime = this.windowMs - (now - oldestRequest) + 100;
40
+ await this.sleep(waitTime);
41
+ }
42
+ this.requestTimes.push(Date.now());
43
+ }
44
+ sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
47
+ }
48
+ // ─── OpenAI API Client ───────────────────────────────────────
49
+ class OpenAIApiClient {
50
+ apiKey;
51
+ baseUrl;
52
+ maxRetries;
53
+ retryDelayMs;
54
+ rateLimiter;
55
+ organizationId;
56
+ constructor(config) {
57
+ // Use nullish coalescing to allow explicit empty string to disable API key
58
+ this.apiKey = config.apiKey ?? process.env.OPENAI_API_KEY ?? '';
59
+ this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
60
+ this.maxRetries = config.maxRetries ?? 3;
61
+ this.retryDelayMs = config.retryDelayMs ?? 1000;
62
+ this.rateLimiter = new RateLimiter();
63
+ this.organizationId = config.organizationId;
64
+ }
65
+ hasApiKey() {
66
+ return !!this.apiKey;
67
+ }
68
+ async request(method, path, body, retryCount = 0) {
69
+ await this.rateLimiter.acquire();
70
+ const headers = {
71
+ 'Content-Type': 'application/json',
72
+ 'Authorization': `Bearer ${this.apiKey}`,
73
+ };
74
+ if (this.organizationId) {
75
+ headers['OpenAI-Organization'] = this.organizationId;
76
+ }
77
+ const url = `${this.baseUrl}${path}`;
78
+ try {
79
+ const response = await fetch(url, {
80
+ method,
81
+ headers,
82
+ body: body ? JSON.stringify(body) : undefined,
83
+ });
84
+ if (!response.ok) {
85
+ const error = new Error();
86
+ error.status = response.status;
87
+ // Handle rate limiting
88
+ if (response.status === 429) {
89
+ const retryAfter = response.headers.get('retry-after');
90
+ error.retryAfter = retryAfter ? parseInt(retryAfter, 10) * 1000 : this.retryDelayMs;
91
+ error.code = 'rate_limit_exceeded';
92
+ error.message = 'Rate limit exceeded';
93
+ if (retryCount < this.maxRetries) {
94
+ await this.sleep(error.retryAfter);
95
+ return this.request(method, path, body, retryCount + 1);
96
+ }
97
+ }
98
+ // Handle transient errors
99
+ if (response.status >= 500 && retryCount < this.maxRetries) {
100
+ await this.sleep(this.retryDelayMs * Math.pow(2, retryCount));
101
+ return this.request(method, path, body, retryCount + 1);
102
+ }
103
+ try {
104
+ const errorBody = (await response.json());
105
+ error.message = errorBody.error?.message || `API error: ${response.status}`;
106
+ error.code = errorBody.error?.type;
107
+ }
108
+ catch {
109
+ error.message = `API error: ${response.status} ${response.statusText}`;
110
+ }
111
+ throw error;
112
+ }
113
+ return response.json();
114
+ }
115
+ catch (err) {
116
+ // Retry on network errors
117
+ if (err instanceof TypeError &&
118
+ err.message.includes('fetch') &&
119
+ retryCount < this.maxRetries) {
120
+ await this.sleep(this.retryDelayMs * Math.pow(2, retryCount));
121
+ return this.request(method, path, body, retryCount + 1);
122
+ }
123
+ throw err;
124
+ }
125
+ }
126
+ sleep(ms) {
127
+ return new Promise((resolve) => setTimeout(resolve, ms));
128
+ }
129
+ // ─── Files API ───────────────────────────────────────────
130
+ async uploadFile(filename, content, purpose = 'assistants', retryCount = 0) {
131
+ await this.rateLimiter.acquire();
132
+ const formData = new FormData();
133
+ formData.append('file', new Blob([content]), filename);
134
+ formData.append('purpose', purpose);
135
+ const headers = {
136
+ 'Authorization': `Bearer ${this.apiKey}`,
137
+ };
138
+ if (this.organizationId) {
139
+ headers['OpenAI-Organization'] = this.organizationId;
140
+ }
141
+ try {
142
+ const response = await fetch(`${this.baseUrl}/files`, {
143
+ method: 'POST',
144
+ headers,
145
+ body: formData,
146
+ });
147
+ if (!response.ok) {
148
+ const error = new Error();
149
+ error.status = response.status;
150
+ // Handle rate limiting
151
+ if (response.status === 429 && retryCount < this.maxRetries) {
152
+ const retryAfter = response.headers.get('retry-after');
153
+ const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : this.retryDelayMs;
154
+ await this.sleep(waitTime);
155
+ return this.uploadFile(filename, content, purpose, retryCount + 1);
156
+ }
157
+ // Handle transient errors
158
+ if (response.status >= 500 && retryCount < this.maxRetries) {
159
+ await this.sleep(this.retryDelayMs * Math.pow(2, retryCount));
160
+ return this.uploadFile(filename, content, purpose, retryCount + 1);
161
+ }
162
+ try {
163
+ const errorBody = (await response.json());
164
+ error.message = errorBody.error?.message || `File upload error: ${response.status}`;
165
+ }
166
+ catch {
167
+ error.message = `File upload error: ${response.status}`;
168
+ }
169
+ throw error;
170
+ }
171
+ return response.json();
172
+ }
173
+ catch (err) {
174
+ // Retry on network errors
175
+ if (err instanceof TypeError &&
176
+ err.message.includes('fetch') &&
177
+ retryCount < this.maxRetries) {
178
+ await this.sleep(this.retryDelayMs * Math.pow(2, retryCount));
179
+ return this.uploadFile(filename, content, purpose, retryCount + 1);
180
+ }
181
+ throw err;
182
+ }
183
+ }
184
+ async listFiles() {
185
+ const response = await this.request('GET', '/files');
186
+ return response.data;
187
+ }
188
+ async deleteFile(fileId) {
189
+ await this.request('DELETE', `/files/${fileId}`);
190
+ }
191
+ }
192
+ // ─── ChatGPT Loader ──────────────────────────────────────────
193
+ export class ChatGPTLoader {
194
+ platform = 'chatgpt';
195
+ version = '1.0.0';
196
+ progress = 0;
197
+ config;
198
+ client;
199
+ state = {
200
+ instructionsProcessed: false,
201
+ memoriesProcessed: false,
202
+ uploadedFileIds: [],
203
+ uploadedFilenames: [],
204
+ lastFileIndex: -1,
205
+ };
206
+ constructor(config = {}) {
207
+ this.config = config;
208
+ this.client = new OpenAIApiClient(config);
209
+ }
210
+ async canLoad() {
211
+ // We can always generate guidance files even without API key
212
+ // API key only needed for file uploads
213
+ return true;
214
+ }
215
+ async load(bundle, options) {
216
+ this.progress = 0;
217
+ const warnings = [];
218
+ const errors = [];
219
+ const manualSteps = [];
220
+ const capabilities = getPlatformCapabilities('chatgpt');
221
+ // Validate bundle target
222
+ if (bundle.target?.platform !== 'chatgpt') {
223
+ throw new Error('Bundle not transformed for ChatGPT');
224
+ }
225
+ // Check for dry run
226
+ if (options.dryRun) {
227
+ return this.dryRunResult(bundle, warnings);
228
+ }
229
+ // Setup output directory for guidance files
230
+ // config.outputDir is trusted (set by developer), but projectName may be user-controlled
231
+ // Sanitize projectName with basename to prevent path injection
232
+ let outputDir;
233
+ if (this.config.outputDir) {
234
+ outputDir = this.config.outputDir;
235
+ }
236
+ else if (options.projectName) {
237
+ outputDir = basename(options.projectName) || 'chatgpt-migration';
238
+ }
239
+ else {
240
+ outputDir = `chatgpt-migration-${new Date().toISOString().split('T')[0]}`;
241
+ }
242
+ this.state.outputDir = outputDir;
243
+ try {
244
+ await mkdir(outputDir, { recursive: true });
245
+ }
246
+ catch {
247
+ // Directory may already exist
248
+ }
249
+ let instructionsLoaded = false;
250
+ let memoriesLoaded = 0;
251
+ let filesLoaded = 0;
252
+ try {
253
+ // Step 1: Process Custom Instructions (20%)
254
+ options.onProgress?.(0.05, 'Processing custom instructions...');
255
+ if (bundle.contents.instructions?.content) {
256
+ const result = await this.processInstructions(bundle.contents.instructions.content, capabilities.instructionLimit, outputDir);
257
+ instructionsLoaded = result.success;
258
+ if (result.warning)
259
+ warnings.push(result.warning);
260
+ if (result.manualStep)
261
+ manualSteps.push(result.manualStep);
262
+ }
263
+ this.progress = 20;
264
+ options.onProgress?.(0.2, 'Instructions processed');
265
+ // Step 2: Process Memories (40%)
266
+ options.onProgress?.(0.25, 'Processing memories...');
267
+ if (bundle.contents.memories?.entries && bundle.contents.memories.entries.length > 0) {
268
+ const result = await this.processMemories(bundle.contents.memories.entries, capabilities.memoryLimit, outputDir);
269
+ memoriesLoaded = result.count;
270
+ if (result.warnings.length > 0)
271
+ warnings.push(...result.warnings);
272
+ if (result.manualStep)
273
+ manualSteps.push(result.manualStep);
274
+ }
275
+ this.progress = 40;
276
+ options.onProgress?.(0.4, 'Memories processed');
277
+ // Step 3: Upload Files (40-85%)
278
+ if (bundle.contents.files?.files && bundle.contents.files.files.length > 0) {
279
+ if (this.client.hasApiKey()) {
280
+ const files = bundle.contents.files.files;
281
+ const startIndex = this.state.lastFileIndex + 1;
282
+ for (let i = startIndex; i < files.length; i++) {
283
+ const file = files[i];
284
+ const fileProgress = 0.4 + (0.45 * (i + 1)) / files.length;
285
+ options.onProgress?.(fileProgress, `Uploading file ${i + 1}/${files.length}: ${file.filename}`);
286
+ try {
287
+ const uploadResult = await this.uploadFile(file, bundle, capabilities.fileSizeLimit);
288
+ if (uploadResult.success) {
289
+ this.state.uploadedFileIds.push(uploadResult.fileId);
290
+ this.state.uploadedFilenames.push(file.filename);
291
+ this.state.lastFileIndex = i;
292
+ filesLoaded++;
293
+ }
294
+ else if (uploadResult.warning) {
295
+ warnings.push(uploadResult.warning);
296
+ }
297
+ }
298
+ catch (err) {
299
+ const errorMsg = err instanceof Error ? err.message : String(err);
300
+ errors.push(`Failed to upload ${file.filename}: ${errorMsg}`);
301
+ }
302
+ }
303
+ }
304
+ else {
305
+ // No API key - provide guidance for manual upload
306
+ const result = await this.generateFileManifest(bundle.contents.files.files, outputDir);
307
+ warnings.push(result.warning);
308
+ if (result.manualStep)
309
+ manualSteps.push(result.manualStep);
310
+ }
311
+ }
312
+ this.progress = 85;
313
+ options.onProgress?.(0.85, 'Files processed');
314
+ // Step 4: Handle Custom GPTs (100%)
315
+ if (bundle.contents.customBots && bundle.contents.customBots.count > 0) {
316
+ options.onProgress?.(0.9, 'Generating GPT configuration guides...');
317
+ const result = await this.generateGPTGuides(bundle.contents.customBots.bots, outputDir);
318
+ if (result.warning)
319
+ warnings.push(result.warning);
320
+ if (result.manualStep)
321
+ manualSteps.push(result.manualStep);
322
+ }
323
+ this.progress = 100;
324
+ options.onProgress?.(1.0, 'Migration complete');
325
+ // Generate summary README
326
+ await this.generateReadme(outputDir, {
327
+ instructionsLoaded,
328
+ memoriesLoaded,
329
+ filesLoaded,
330
+ hasApiKey: this.client.hasApiKey(),
331
+ totalFiles: bundle.contents.files?.count ?? 0,
332
+ hasGPTs: (bundle.contents.customBots?.count ?? 0) > 0,
333
+ });
334
+ return {
335
+ success: true,
336
+ loaded: {
337
+ instructions: instructionsLoaded,
338
+ memories: memoriesLoaded,
339
+ files: filesLoaded,
340
+ customBots: 0, // Always requires manual creation
341
+ },
342
+ created: {
343
+ projectId: outputDir,
344
+ projectUrl: `file://${outputDir}`,
345
+ },
346
+ warnings,
347
+ errors,
348
+ manualSteps: manualSteps.length > 0 ? manualSteps : undefined,
349
+ };
350
+ }
351
+ catch (err) {
352
+ const errorMsg = err instanceof Error ? err.message : String(err);
353
+ errors.push(`Load failed: ${errorMsg}`);
354
+ return {
355
+ success: false,
356
+ loaded: {
357
+ instructions: this.state.instructionsProcessed,
358
+ memories: this.state.memoriesProcessed ? memoriesLoaded : 0,
359
+ files: this.state.uploadedFileIds.length,
360
+ customBots: 0,
361
+ },
362
+ created: this.state.outputDir
363
+ ? {
364
+ projectId: this.state.outputDir,
365
+ projectUrl: `file://${this.state.outputDir}`,
366
+ }
367
+ : undefined,
368
+ warnings,
369
+ errors,
370
+ };
371
+ }
372
+ }
373
+ getProgress() {
374
+ return this.progress;
375
+ }
376
+ /**
377
+ * Get current load state for checkpointing.
378
+ */
379
+ getLoadState() {
380
+ return { ...this.state };
381
+ }
382
+ /**
383
+ * Set load state for resume.
384
+ */
385
+ setLoadState(state) {
386
+ this.state = { ...state };
387
+ }
388
+ // ─── Private Helpers ─────────────────────────────────────
389
+ async processInstructions(content, limit, outputDir) {
390
+ let processedContent = content;
391
+ let warning;
392
+ // Truncate if exceeds limit
393
+ if (content.length > limit) {
394
+ const result = intelligentTruncate(content, limit);
395
+ processedContent = result.content || content.substring(0, limit);
396
+ warning = `Instructions truncated: ${content.length} → ${processedContent.length} chars`;
397
+ }
398
+ // Write instruction file for manual copy
399
+ const instructionFile = join(outputDir, 'custom-instructions.txt');
400
+ await writeFile(instructionFile, processedContent);
401
+ // Also write the full content if truncated
402
+ if (content.length > limit) {
403
+ const fullFile = join(outputDir, 'custom-instructions-full.txt');
404
+ await writeFile(fullFile, content);
405
+ }
406
+ this.state.instructionsProcessed = true;
407
+ return {
408
+ success: true,
409
+ warning,
410
+ manualStep: `Copy custom instructions from "${instructionFile}" to ChatGPT Settings → Personalization → Custom Instructions`,
411
+ };
412
+ }
413
+ async processMemories(entries, limit, outputDir) {
414
+ const warnings = [];
415
+ let processedEntries = entries;
416
+ // Truncate to memory limit
417
+ if (entries.length > limit) {
418
+ processedEntries = entries.slice(0, limit);
419
+ warnings.push(`Memories truncated: ${entries.length} → ${limit} (ChatGPT limit)`);
420
+ }
421
+ // Format memories for manual addition
422
+ const formattedMemories = this.formatMemoriesForManualEntry(processedEntries);
423
+ const memoriesFile = join(outputDir, 'memories.md');
424
+ await writeFile(memoriesFile, formattedMemories);
425
+ // Also write as JSON for programmatic access
426
+ const memoriesJsonFile = join(outputDir, 'memories.json');
427
+ await writeFile(memoriesJsonFile, JSON.stringify(processedEntries, null, 2));
428
+ this.state.memoriesProcessed = true;
429
+ return {
430
+ count: processedEntries.length,
431
+ warnings,
432
+ manualStep: `Add memories from "${memoriesFile}" to ChatGPT Settings → Personalization → Memory. Each bullet point is a separate memory entry.`,
433
+ };
434
+ }
435
+ formatMemoriesForManualEntry(entries) {
436
+ const lines = [
437
+ '# ChatGPT Memories to Add',
438
+ '',
439
+ '> Each bullet point below should be added as a separate memory in ChatGPT.',
440
+ '> Go to: ChatGPT → Settings → Personalization → Memory → Manage',
441
+ '> Click "Create memory" for each entry.',
442
+ '',
443
+ '---',
444
+ '',
445
+ ];
446
+ // Group by category if available
447
+ const byCategory = new Map();
448
+ for (const entry of entries) {
449
+ const category = entry.category || 'General';
450
+ if (!byCategory.has(category)) {
451
+ byCategory.set(category, []);
452
+ }
453
+ byCategory.get(category).push(entry);
454
+ }
455
+ for (const [category, items] of byCategory) {
456
+ lines.push(`## ${category}`, '');
457
+ for (const item of items) {
458
+ // Truncate individual memories if needed (ChatGPT has ~500 char limit per memory)
459
+ const content = item.content.length > 450
460
+ ? item.content.substring(0, 447) + '...'
461
+ : item.content;
462
+ lines.push(`- ${content}`);
463
+ }
464
+ lines.push('');
465
+ }
466
+ lines.push('---', '', `Total: ${entries.length} memories`);
467
+ return lines.join('\n');
468
+ }
469
+ async uploadFile(file, bundle, sizeLimit) {
470
+ // Check file size
471
+ if (file.size > sizeLimit) {
472
+ return {
473
+ success: false,
474
+ warning: `File ${file.filename} exceeds size limit (${file.size} > ${sizeLimit} bytes)`,
475
+ };
476
+ }
477
+ // Validate file path is within bundle directory to prevent path traversal
478
+ const bundleDir = bundle.source.bundlePath || process.cwd();
479
+ const resolvedPath = resolve(bundleDir, file.path);
480
+ const rel = relative(bundleDir, resolvedPath);
481
+ if (rel.startsWith('..') || resolve(rel) === rel) {
482
+ return {
483
+ success: false,
484
+ warning: `Invalid file path: ${file.path}`,
485
+ };
486
+ }
487
+ // Read file content using the validated path
488
+ if (!existsSync(resolvedPath)) {
489
+ return {
490
+ success: false,
491
+ warning: `File not found: ${resolvedPath}`,
492
+ };
493
+ }
494
+ const content = await readFile(resolvedPath);
495
+ const result = await this.client.uploadFile(basename(file.filename), content, 'assistants');
496
+ return { success: true, fileId: result.id };
497
+ }
498
+ async generateFileManifest(files, outputDir) {
499
+ const manifestLines = [
500
+ '# Files to Upload to ChatGPT',
501
+ '',
502
+ '> These files were part of your migration bundle.',
503
+ '> Upload them manually to ChatGPT or use with the Assistants API.',
504
+ '',
505
+ '| Filename | Size | Type | Path |',
506
+ '|----------|------|------|------|',
507
+ ];
508
+ for (const file of files) {
509
+ const sizeKB = Math.round(file.size / 1024);
510
+ manifestLines.push(`| ${file.filename} | ${sizeKB} KB | ${file.mimeType} | ${file.path} |`);
511
+ }
512
+ const manifestFile = join(outputDir, 'files-manifest.md');
513
+ await writeFile(manifestFile, manifestLines.join('\n'));
514
+ return {
515
+ warning: `No API key provided. ${files.length} files need manual upload. See ${manifestFile}`,
516
+ manualStep: `Upload files listed in "${manifestFile}" to ChatGPT when starting a conversation, or use them with an Assistant.`,
517
+ };
518
+ }
519
+ async generateGPTGuides(bots, outputDir) {
520
+ const gptsDir = join(outputDir, 'gpts');
521
+ await mkdir(gptsDir, { recursive: true });
522
+ for (const bot of bots) {
523
+ const gptGuide = this.formatGPTGuide(bot);
524
+ const safeFilename = bot.name.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase();
525
+ const gptFile = join(gptsDir, `${safeFilename}.md`);
526
+ await writeFile(gptFile, gptGuide);
527
+ }
528
+ // Create an index file
529
+ const indexLines = [
530
+ '# Custom GPTs to Create',
531
+ '',
532
+ '> These GPT configurations were extracted from your previous assistant.',
533
+ '> Create them manually at: https://chat.openai.com/gpts/editor',
534
+ '',
535
+ '## GPTs',
536
+ '',
537
+ ];
538
+ for (const bot of bots) {
539
+ const safeFilename = bot.name.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase();
540
+ indexLines.push(`- [${bot.name}](gpts/${safeFilename}.md)`);
541
+ }
542
+ const indexFile = join(outputDir, 'gpts-index.md');
543
+ await writeFile(indexFile, indexLines.join('\n'));
544
+ return {
545
+ warning: `${bots.length} GPT configurations exported. Manual creation required.`,
546
+ manualStep: `Create custom GPTs using the guides in "${gptsDir}/". Visit https://chat.openai.com/gpts/editor to create each one.`,
547
+ };
548
+ }
549
+ formatGPTGuide(bot) {
550
+ const lines = [
551
+ `# ${bot.name}`,
552
+ '',
553
+ '## How to Create This GPT',
554
+ '',
555
+ '1. Go to https://chat.openai.com/gpts/editor',
556
+ '2. Click "Create a GPT"',
557
+ '3. Configure using the settings below',
558
+ '',
559
+ '---',
560
+ '',
561
+ '## Configuration',
562
+ '',
563
+ '### Name',
564
+ '',
565
+ bot.name,
566
+ '',
567
+ '### Description',
568
+ '',
569
+ bot.description || '(No description)',
570
+ '',
571
+ '### Instructions',
572
+ '',
573
+ '```',
574
+ bot.instructions,
575
+ '```',
576
+ '',
577
+ ];
578
+ if (bot.capabilities && bot.capabilities.length > 0) {
579
+ lines.push('### Capabilities', '');
580
+ for (const cap of bot.capabilities) {
581
+ lines.push(`- ${cap}`);
582
+ }
583
+ lines.push('');
584
+ }
585
+ if (bot.knowledgeFiles && bot.knowledgeFiles.length > 0) {
586
+ lines.push('### Knowledge Files', '');
587
+ for (const file of bot.knowledgeFiles) {
588
+ lines.push(`- ${file}`);
589
+ }
590
+ lines.push('');
591
+ }
592
+ return lines.join('\n');
593
+ }
594
+ async generateReadme(outputDir, stats) {
595
+ const lines = [
596
+ '# ChatGPT Migration Package',
597
+ '',
598
+ `> Generated: ${new Date().toISOString()}`,
599
+ '',
600
+ '## Overview',
601
+ '',
602
+ 'This package contains your migrated data from Claude, ready to be applied to ChatGPT.',
603
+ '',
604
+ '## Status',
605
+ '',
606
+ `| Item | Status |`,
607
+ `|------|--------|`,
608
+ `| Custom Instructions | ${stats.instructionsLoaded ? '✅ Ready' : '❌ None'} |`,
609
+ `| Memories | ${stats.memoriesLoaded > 0 ? `✅ ${stats.memoriesLoaded} entries` : '❌ None'} |`,
610
+ `| Files | ${stats.filesLoaded > 0 ? `✅ ${stats.filesLoaded} uploaded` : stats.totalFiles > 0 ? `⏳ ${stats.totalFiles} pending` : '❌ None'} |`,
611
+ `| Custom GPTs | ${stats.hasGPTs ? '⏳ Guides generated' : '❌ None'} |`,
612
+ '',
613
+ '## Manual Steps Required',
614
+ '',
615
+ ];
616
+ if (stats.instructionsLoaded) {
617
+ lines.push('### 1. Set Custom Instructions', '', '1. Open ChatGPT → Settings (⚙️) → Personalization → Custom Instructions', '2. Open `custom-instructions.txt` in this folder', '3. Copy the content into the "How would you like ChatGPT to respond?" field', '4. Save', '');
618
+ }
619
+ if (stats.memoriesLoaded > 0) {
620
+ lines.push('### 2. Add Memories', '', '1. Open ChatGPT → Settings (⚙️) → Personalization → Memory → Manage', '2. Open `memories.md` in this folder', '3. For each bullet point, click "Create memory" and paste the content', '4. Repeat for all memories (or prioritize the most important ones)', '', `> Note: ChatGPT has a limit of ~100 memories. ${stats.memoriesLoaded} memories are included.`, '');
621
+ }
622
+ if (stats.totalFiles > 0 && !stats.hasApiKey) {
623
+ lines.push('### 3. Upload Files', '', '1. See `files-manifest.md` for the list of files', '2. Upload files to ChatGPT when starting a new conversation', '3. Or use the OpenAI API with an API key for programmatic upload', '');
624
+ }
625
+ if (stats.hasGPTs) {
626
+ lines.push('### 4. Create Custom GPTs', '', '1. See the `gpts/` folder for configuration guides', '2. Visit https://chat.openai.com/gpts/editor', '3. Create each GPT using the provided instructions', '');
627
+ }
628
+ lines.push('## Files in This Package', '', '```', outputDir + '/');
629
+ if (stats.instructionsLoaded) {
630
+ lines.push('├── custom-instructions.txt # Copy to ChatGPT settings');
631
+ lines.push('├── custom-instructions-full.txt # Full content (if truncated)');
632
+ }
633
+ if (stats.memoriesLoaded > 0) {
634
+ lines.push('├── memories.md # Memories to add manually');
635
+ lines.push('├── memories.json # Memories in JSON format');
636
+ }
637
+ if (stats.totalFiles > 0) {
638
+ lines.push('├── files-manifest.md # List of files to upload');
639
+ }
640
+ if (stats.hasGPTs) {
641
+ lines.push('├── gpts-index.md # Index of GPT configurations');
642
+ lines.push('└── gpts/ # GPT configuration guides');
643
+ }
644
+ lines.push('```', '', '---', '', 'Need help? Visit https://savestate.dev/docs/migration');
645
+ const readmeFile = join(outputDir, 'README.md');
646
+ await writeFile(readmeFile, lines.join('\n'));
647
+ }
648
+ dryRunResult(bundle, warnings) {
649
+ const capabilities = getPlatformCapabilities('chatgpt');
650
+ // Check for potential issues
651
+ if (bundle.contents.instructions?.content &&
652
+ bundle.contents.instructions.content.length > capabilities.instructionLimit) {
653
+ warnings.push(`Instructions would be truncated: ${bundle.contents.instructions.content.length} > ${capabilities.instructionLimit} chars`);
654
+ }
655
+ if (bundle.contents.memories?.entries &&
656
+ bundle.contents.memories.entries.length > capabilities.memoryLimit) {
657
+ warnings.push(`Memories would be truncated: ${bundle.contents.memories.entries.length} > ${capabilities.memoryLimit} entries`);
658
+ }
659
+ if (bundle.contents.files?.files) {
660
+ for (const file of bundle.contents.files.files) {
661
+ if (file.size > capabilities.fileSizeLimit) {
662
+ warnings.push(`File ${file.filename} exceeds size limit`);
663
+ }
664
+ }
665
+ }
666
+ warnings.push('Dry run - no changes made');
667
+ const manualSteps = [
668
+ 'Custom Instructions: Must be copied manually to ChatGPT settings',
669
+ 'Memories: Must be added manually in ChatGPT settings',
670
+ ];
671
+ if (!this.client.hasApiKey()) {
672
+ manualSteps.push('Files: Must be uploaded manually (no API key provided)');
673
+ }
674
+ if (bundle.contents.customBots && bundle.contents.customBots.count > 0) {
675
+ manualSteps.push(`${bundle.contents.customBots.count} GPT(s): Must be created manually`);
676
+ }
677
+ return {
678
+ success: true,
679
+ loaded: {
680
+ instructions: !!bundle.contents.instructions,
681
+ memories: bundle.contents.memories?.count ?? 0,
682
+ files: bundle.contents.files?.count ?? 0,
683
+ customBots: 0,
684
+ },
685
+ warnings,
686
+ errors: [],
687
+ manualSteps,
688
+ };
689
+ }
690
+ }
691
+ //# sourceMappingURL=chatgpt.js.map