@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.
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Claude Web (claude.ai) Adapter
3
+ *
4
+ * Adapter for Claude consumer web interface (claude.ai).
5
+ * This is DIFFERENT from the claude-code adapter which handles
6
+ * Claude Code CLI projects. This adapter handles:
7
+ *
8
+ * - Claude.ai data export (conversations, account info)
9
+ * - Claude memory export (text/markdown memories)
10
+ * - Claude Projects (instructions, uploaded files, conversations)
11
+ *
12
+ * Data sources:
13
+ * 1. SAVESTATE_CLAUDE_EXPORT env var → export directory
14
+ * 2. claude-export/ in current directory
15
+ * 3. .savestate/imports/claude/ directory
16
+ * 4. Standalone memory file (claude-memories.md or .txt)
17
+ *
18
+ * Export format (Anthropic data export):
19
+ * conversations/ — JSON files per conversation
20
+ * account_info.json — user profile
21
+ * projects/ — project data (instructions, files)
22
+ *
23
+ * Conversation JSON format:
24
+ * { uuid, name, created_at, updated_at, chat_messages: [{ uuid, text, sender, created_at, attachments, files }] }
25
+ */
26
+ import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises';
27
+ import { existsSync } from 'node:fs';
28
+ import { join, extname, basename } from 'node:path';
29
+ import { SAF_VERSION, generateSnapshotId, computeChecksum } from '../format.js';
30
+ // ─── Constants ───────────────────────────────────────────────
31
+ /** Possible export directory names to search for */
32
+ const EXPORT_DIR_CANDIDATES = [
33
+ 'claude-export',
34
+ 'claude_export',
35
+ 'claude-data-export',
36
+ 'claude_data_export',
37
+ ];
38
+ /** Possible memory file names */
39
+ const MEMORY_FILE_CANDIDATES = [
40
+ 'claude-memories.md',
41
+ 'claude-memories.txt',
42
+ 'claude_memories.md',
43
+ 'claude_memories.txt',
44
+ 'memories.md',
45
+ 'memories.txt',
46
+ ];
47
+ /** Maximum file size to read (5MB for export files) */
48
+ const MAX_FILE_SIZE = 5 * 1024 * 1024;
49
+ export class ClaudeWebAdapter {
50
+ id = 'claude-web';
51
+ name = 'Claude Web (claude.ai)';
52
+ platform = 'claude-web';
53
+ version = '0.1.0';
54
+ exportDir = null;
55
+ memoryFile = null;
56
+ baseDir;
57
+ warnings = [];
58
+ constructor(baseDir) {
59
+ this.baseDir = baseDir ?? process.cwd();
60
+ }
61
+ async detect() {
62
+ const result = await this.findDataSources();
63
+ return result.found;
64
+ }
65
+ async extract() {
66
+ this.warnings = [];
67
+ const sources = await this.findDataSources();
68
+ if (!sources.found) {
69
+ throw new Error('No Claude.ai data found. Set SAVESTATE_CLAUDE_EXPORT to your export directory, ' +
70
+ 'place export in claude-export/, or provide a claude-memories.md file.');
71
+ }
72
+ this.exportDir = sources.exportDir;
73
+ this.memoryFile = sources.memoryFile;
74
+ // Extract all data
75
+ const accountInfo = await this.readAccountInfo();
76
+ const conversations = await this.readConversations();
77
+ const memoryEntries = await this.readMemories();
78
+ const projects = await this.readProjects();
79
+ const knowledge = this.buildKnowledge(projects);
80
+ // Build personality from project instructions
81
+ const personality = this.buildPersonality(projects);
82
+ const snapshotId = generateSnapshotId();
83
+ const now = new Date().toISOString();
84
+ // Report findings
85
+ const convoCount = conversations.length;
86
+ const memoryCount = memoryEntries.length;
87
+ const projectCount = projects.length;
88
+ console.log(` Found ${convoCount} conversations, ${memoryCount} memories, ${projectCount} projects`);
89
+ if (this.warnings.length > 0) {
90
+ for (const w of this.warnings) {
91
+ console.warn(` ⚠ ${w}`);
92
+ }
93
+ }
94
+ // Build conversation index
95
+ const conversationIndex = conversations.map((c) => ({
96
+ id: c.id,
97
+ title: c.title,
98
+ createdAt: c.createdAt,
99
+ updatedAt: c.updatedAt,
100
+ messageCount: c.messages.length,
101
+ path: `conversations/${c.id}.json`,
102
+ }));
103
+ const snapshot = {
104
+ manifest: {
105
+ version: SAF_VERSION,
106
+ timestamp: now,
107
+ id: snapshotId,
108
+ platform: this.platform,
109
+ adapter: this.id,
110
+ checksum: '',
111
+ size: 0,
112
+ },
113
+ identity: {
114
+ personality: personality || undefined,
115
+ config: accountInfo ? { accountInfo } : undefined,
116
+ tools: [],
117
+ },
118
+ memory: {
119
+ core: memoryEntries,
120
+ knowledge,
121
+ },
122
+ conversations: {
123
+ total: conversationIndex.length,
124
+ conversations: conversationIndex,
125
+ },
126
+ platform: await this.identify(),
127
+ chain: {
128
+ current: snapshotId,
129
+ ancestors: [],
130
+ },
131
+ restoreHints: {
132
+ platform: this.platform,
133
+ steps: [
134
+ {
135
+ type: 'manual',
136
+ description: 'Import memories via Claude.ai Settings → Memory',
137
+ target: 'memory/',
138
+ },
139
+ {
140
+ type: 'manual',
141
+ description: 'Create Claude Projects with restored instructions and files',
142
+ target: 'identity/',
143
+ },
144
+ {
145
+ type: 'file',
146
+ description: 'Generate claude-restore-guide.md with organized restore instructions',
147
+ target: 'claude-restore-guide.md',
148
+ },
149
+ ],
150
+ manualSteps: [
151
+ 'Claude.ai does not support automated restore — data must be manually re-entered',
152
+ 'Memories: Copy each memory entry from the restore guide into Claude.ai Settings → Memory',
153
+ 'Projects: Create new Projects in Claude.ai and paste instructions + upload files',
154
+ 'Conversations: Cannot be restored (read-only history)',
155
+ ],
156
+ },
157
+ };
158
+ return snapshot;
159
+ }
160
+ async restore(snapshot) {
161
+ // Claude consumer has limited restore capabilities.
162
+ // Generate a comprehensive restore guide for manual import.
163
+ const restoreDir = join(this.baseDir, '.savestate', 'restore', 'claude-web');
164
+ await mkdir(restoreDir, { recursive: true });
165
+ const guide = this.generateRestoreGuide(snapshot);
166
+ const guidePath = join(restoreDir, 'claude-restore-guide.md');
167
+ await writeFile(guidePath, guide, 'utf-8');
168
+ // Export memories as a standalone file for easy copy-paste
169
+ if (snapshot.memory.core.length > 0) {
170
+ const memoriesContent = this.formatMemoriesForRestore(snapshot.memory.core);
171
+ const memoriesPath = join(restoreDir, 'memories-to-import.md');
172
+ await writeFile(memoriesPath, memoriesContent, 'utf-8');
173
+ console.log(` 📝 Memories file: ${memoriesPath}`);
174
+ }
175
+ // Export project instructions as individual files
176
+ if (snapshot.identity.personality) {
177
+ const projectsDir = join(restoreDir, 'projects');
178
+ await mkdir(projectsDir, { recursive: true });
179
+ const instructionsPath = join(projectsDir, 'project-instructions.md');
180
+ await writeFile(instructionsPath, snapshot.identity.personality, 'utf-8');
181
+ console.log(` 📋 Project instructions: ${instructionsPath}`);
182
+ }
183
+ console.log(` 📖 Restore guide: ${guidePath}`);
184
+ console.log();
185
+ console.log(' ℹ️ Claude.ai requires manual restore. See the guide for step-by-step instructions.');
186
+ }
187
+ async identify() {
188
+ const accountInfo = await this.readAccountInfo();
189
+ return {
190
+ name: 'Claude Web (claude.ai)',
191
+ version: 'consumer',
192
+ exportMethod: 'data-export',
193
+ accountId: accountInfo?.uuid ?? accountInfo?.email ?? undefined,
194
+ };
195
+ }
196
+ // ─── Private: Data Source Discovery ───────────────────────
197
+ async findDataSources() {
198
+ let exportDir = null;
199
+ let memoryFile = null;
200
+ // 1. Check SAVESTATE_CLAUDE_EXPORT env var
201
+ const envDir = process.env.SAVESTATE_CLAUDE_EXPORT;
202
+ if (envDir && existsSync(envDir)) {
203
+ const s = await stat(envDir).catch(() => null);
204
+ if (s?.isDirectory()) {
205
+ exportDir = envDir;
206
+ }
207
+ }
208
+ // 2. Check standard directory names in current dir
209
+ if (!exportDir) {
210
+ for (const candidate of EXPORT_DIR_CANDIDATES) {
211
+ const candidatePath = join(this.baseDir, candidate);
212
+ if (existsSync(candidatePath)) {
213
+ const s = await stat(candidatePath).catch(() => null);
214
+ if (s?.isDirectory()) {
215
+ exportDir = candidatePath;
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ // 3. Check .savestate/imports/claude/
222
+ if (!exportDir) {
223
+ const importsDir = join(this.baseDir, '.savestate', 'imports', 'claude');
224
+ if (existsSync(importsDir)) {
225
+ const s = await stat(importsDir).catch(() => null);
226
+ if (s?.isDirectory()) {
227
+ exportDir = importsDir;
228
+ }
229
+ }
230
+ }
231
+ // 4. Check for standalone memory file
232
+ for (const candidate of MEMORY_FILE_CANDIDATES) {
233
+ // Check in base dir
234
+ const basePath = join(this.baseDir, candidate);
235
+ if (existsSync(basePath)) {
236
+ memoryFile = basePath;
237
+ break;
238
+ }
239
+ // Check in export dir if found
240
+ if (exportDir) {
241
+ const exportPath = join(exportDir, candidate);
242
+ if (existsSync(exportPath)) {
243
+ memoryFile = exportPath;
244
+ break;
245
+ }
246
+ }
247
+ // Check in .savestate/imports/claude/
248
+ const importPath = join(this.baseDir, '.savestate', 'imports', 'claude', candidate);
249
+ if (existsSync(importPath)) {
250
+ memoryFile = importPath;
251
+ break;
252
+ }
253
+ }
254
+ return {
255
+ found: exportDir !== null || memoryFile !== null,
256
+ exportDir,
257
+ memoryFile,
258
+ };
259
+ }
260
+ // ─── Private: Account Info ────────────────────────────────
261
+ async readAccountInfo() {
262
+ if (!this.exportDir)
263
+ return null;
264
+ const candidates = ['account_info.json', 'account.json', 'profile.json'];
265
+ for (const filename of candidates) {
266
+ const filePath = join(this.exportDir, filename);
267
+ if (existsSync(filePath)) {
268
+ const content = await this.safeReadFile(filePath);
269
+ if (content) {
270
+ try {
271
+ return JSON.parse(content);
272
+ }
273
+ catch {
274
+ this.warnings.push(`Failed to parse ${filename}`);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+ // ─── Private: Conversations ───────────────────────────────
282
+ async readConversations() {
283
+ if (!this.exportDir)
284
+ return [];
285
+ const conversations = [];
286
+ // Look for conversations/ directory
287
+ const convDirCandidates = ['conversations', 'chats'];
288
+ for (const dirName of convDirCandidates) {
289
+ const convDir = join(this.exportDir, dirName);
290
+ if (!existsSync(convDir))
291
+ continue;
292
+ const s = await stat(convDir).catch(() => null);
293
+ if (!s?.isDirectory())
294
+ continue;
295
+ const files = await readdir(convDir).catch(() => []);
296
+ for (const file of files) {
297
+ if (!file.endsWith('.json'))
298
+ continue;
299
+ const filePath = join(convDir, file);
300
+ const parsed = await this.parseConversationFile(filePath);
301
+ if (parsed) {
302
+ conversations.push(parsed);
303
+ }
304
+ }
305
+ }
306
+ // Also check if there's a single conversations.json file (some export formats)
307
+ const singleFile = join(this.exportDir, 'conversations.json');
308
+ if (existsSync(singleFile) && conversations.length === 0) {
309
+ const content = await this.safeReadFile(singleFile);
310
+ if (content) {
311
+ try {
312
+ const data = JSON.parse(content);
313
+ // Could be an array of conversations
314
+ if (Array.isArray(data)) {
315
+ for (const raw of data) {
316
+ const parsed = this.parseConversationObject(raw);
317
+ if (parsed) {
318
+ conversations.push(parsed);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ catch {
324
+ this.warnings.push('Failed to parse conversations.json');
325
+ }
326
+ }
327
+ }
328
+ return conversations;
329
+ }
330
+ async parseConversationFile(filePath) {
331
+ const content = await this.safeReadFile(filePath);
332
+ if (!content)
333
+ return null;
334
+ try {
335
+ const raw = JSON.parse(content);
336
+ return this.parseConversationObject(raw);
337
+ }
338
+ catch {
339
+ this.warnings.push(`Failed to parse conversation: ${basename(filePath)}`);
340
+ return null;
341
+ }
342
+ }
343
+ parseConversationObject(raw) {
344
+ if (!raw)
345
+ return null;
346
+ const id = raw.uuid ?? raw.id ?? `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
347
+ const title = raw.name ?? raw.title ?? undefined;
348
+ const createdAt = raw.created_at ?? new Date().toISOString();
349
+ const updatedAt = raw.updated_at ?? createdAt;
350
+ // Handle both "chat_messages" and "messages" field names
351
+ const rawMessages = raw.chat_messages ?? raw.messages ?? [];
352
+ const messages = [];
353
+ for (const msg of rawMessages) {
354
+ const parsed = this.parseMessage(msg);
355
+ if (parsed) {
356
+ messages.push(parsed);
357
+ }
358
+ }
359
+ if (messages.length === 0)
360
+ return null;
361
+ return { id, title, createdAt, updatedAt, messages };
362
+ }
363
+ parseMessage(msg) {
364
+ if (!msg)
365
+ return null;
366
+ const id = msg.uuid ?? msg.id ?? `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
367
+ const text = msg.text ?? msg.content ?? '';
368
+ if (!text && (!msg.attachments || msg.attachments.length === 0)) {
369
+ return null; // Skip empty messages
370
+ }
371
+ // Map Claude's sender field to SaveState roles
372
+ const rawRole = msg.sender ?? msg.role ?? 'user';
373
+ const role = this.mapRole(rawRole);
374
+ const timestamp = msg.created_at ?? msg.timestamp ?? new Date().toISOString();
375
+ // Build content including attachment references
376
+ let content = text;
377
+ if (msg.attachments && msg.attachments.length > 0) {
378
+ const attachmentTexts = msg.attachments
379
+ .filter((a) => a.extracted_content)
380
+ .map((a) => `\n[Attachment: ${a.file_name ?? 'unknown'}]\n${a.extracted_content}`);
381
+ if (attachmentTexts.length > 0) {
382
+ content += attachmentTexts.join('\n');
383
+ }
384
+ }
385
+ // Build metadata for file references
386
+ const metadata = {};
387
+ if (msg.attachments && msg.attachments.length > 0) {
388
+ metadata.attachments = msg.attachments.map((a) => ({
389
+ fileName: a.file_name,
390
+ fileType: a.file_type,
391
+ fileSize: a.file_size,
392
+ }));
393
+ }
394
+ if (msg.files && msg.files.length > 0) {
395
+ metadata.files = msg.files.map((f) => ({
396
+ fileName: f.file_name,
397
+ fileId: f.file_id,
398
+ }));
399
+ }
400
+ return {
401
+ id,
402
+ role,
403
+ content,
404
+ timestamp,
405
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
406
+ };
407
+ }
408
+ /**
409
+ * Map Claude's sender/role names to SaveState standard roles.
410
+ * Claude uses "human"/"assistant", SaveState uses "user"/"assistant".
411
+ */
412
+ mapRole(role) {
413
+ const normalized = role.toLowerCase().trim();
414
+ switch (normalized) {
415
+ case 'human':
416
+ case 'user':
417
+ return 'user';
418
+ case 'assistant':
419
+ case 'claude':
420
+ case 'ai':
421
+ return 'assistant';
422
+ case 'system':
423
+ return 'system';
424
+ case 'tool':
425
+ case 'tool_result':
426
+ return 'tool';
427
+ default:
428
+ return 'user';
429
+ }
430
+ }
431
+ // ─── Private: Memories ────────────────────────────────────
432
+ async readMemories() {
433
+ const entries = [];
434
+ // 1. Read standalone memory file
435
+ if (this.memoryFile) {
436
+ const parsed = await this.parseMemoryFile(this.memoryFile);
437
+ entries.push(...parsed);
438
+ }
439
+ // 2. Look for memory files in export directory
440
+ if (this.exportDir) {
441
+ for (const candidate of MEMORY_FILE_CANDIDATES) {
442
+ const filePath = join(this.exportDir, candidate);
443
+ // Skip if this is the same as the standalone memory file
444
+ if (this.memoryFile && filePath === this.memoryFile)
445
+ continue;
446
+ if (existsSync(filePath)) {
447
+ const parsed = await this.parseMemoryFile(filePath);
448
+ entries.push(...parsed);
449
+ }
450
+ }
451
+ // Also check for a memories/ directory
452
+ const memoriesDir = join(this.exportDir, 'memories');
453
+ if (existsSync(memoriesDir)) {
454
+ const s = await stat(memoriesDir).catch(() => null);
455
+ if (s?.isDirectory()) {
456
+ const files = await readdir(memoriesDir).catch(() => []);
457
+ for (const file of files) {
458
+ const filePath = join(memoriesDir, file);
459
+ const ext = extname(file).toLowerCase();
460
+ if (ext === '.json') {
461
+ const jsonEntries = await this.parseMemoryJson(filePath);
462
+ entries.push(...jsonEntries);
463
+ }
464
+ else if (ext === '.md' || ext === '.txt') {
465
+ const textEntries = await this.parseMemoryFile(filePath);
466
+ entries.push(...textEntries);
467
+ }
468
+ }
469
+ }
470
+ }
471
+ }
472
+ return entries;
473
+ }
474
+ /**
475
+ * Parse a Claude memory text/markdown file.
476
+ * Memories are typically one per line or separated by blank lines.
477
+ * May have bullet points (- or *) or numbered entries.
478
+ */
479
+ async parseMemoryFile(filePath) {
480
+ const content = await this.safeReadFile(filePath);
481
+ if (!content)
482
+ return [];
483
+ const entries = [];
484
+ const fileStat = await stat(filePath).catch(() => null);
485
+ const fileDate = fileStat?.mtime?.toISOString() ?? new Date().toISOString();
486
+ const source = `claude-memory:${basename(filePath)}`;
487
+ // Split into individual memory entries
488
+ // Handle various formats: bullet lists, numbered lists, blank-line separated
489
+ const lines = content.split('\n');
490
+ let currentEntry = '';
491
+ let entryIndex = 0;
492
+ for (const line of lines) {
493
+ const trimmed = line.trim();
494
+ // Skip empty lines (they separate entries) or headers
495
+ if (!trimmed) {
496
+ if (currentEntry.trim()) {
497
+ entries.push(this.createMemoryEntry(currentEntry.trim(), source, fileDate, entryIndex));
498
+ entryIndex++;
499
+ currentEntry = '';
500
+ }
501
+ continue;
502
+ }
503
+ // Skip markdown headers (used as section dividers, not memories themselves)
504
+ if (trimmed.startsWith('# ') || trimmed.startsWith('## ') || trimmed.startsWith('### ')) {
505
+ if (currentEntry.trim()) {
506
+ entries.push(this.createMemoryEntry(currentEntry.trim(), source, fileDate, entryIndex));
507
+ entryIndex++;
508
+ currentEntry = '';
509
+ }
510
+ continue;
511
+ }
512
+ // Check if this line starts a new entry (bullet or numbered list)
513
+ const isBullet = /^[-*•]\s/.test(trimmed);
514
+ const isNumbered = /^\d+[.)]\s/.test(trimmed);
515
+ if ((isBullet || isNumbered) && currentEntry.trim()) {
516
+ // Save previous entry and start new one
517
+ entries.push(this.createMemoryEntry(currentEntry.trim(), source, fileDate, entryIndex));
518
+ entryIndex++;
519
+ // Strip the bullet/number prefix
520
+ currentEntry = trimmed.replace(/^[-*•]\s+/, '').replace(/^\d+[.)]\s+/, '');
521
+ }
522
+ else if (isBullet || isNumbered) {
523
+ currentEntry = trimmed.replace(/^[-*•]\s+/, '').replace(/^\d+[.)]\s+/, '');
524
+ }
525
+ else {
526
+ // Continuation of current entry
527
+ currentEntry += (currentEntry ? ' ' : '') + trimmed;
528
+ }
529
+ }
530
+ // Don't forget the last entry
531
+ if (currentEntry.trim()) {
532
+ entries.push(this.createMemoryEntry(currentEntry.trim(), source, fileDate, entryIndex));
533
+ }
534
+ return entries;
535
+ }
536
+ createMemoryEntry(content, source, date, index) {
537
+ return {
538
+ id: `claude-memory-${index}`,
539
+ content,
540
+ source,
541
+ createdAt: date,
542
+ metadata: { platform: 'claude.ai' },
543
+ };
544
+ }
545
+ /**
546
+ * Parse a JSON memory file (if Claude exports memories in JSON format).
547
+ */
548
+ async parseMemoryJson(filePath) {
549
+ const content = await this.safeReadFile(filePath);
550
+ if (!content)
551
+ return [];
552
+ try {
553
+ const data = JSON.parse(content);
554
+ const entries = [];
555
+ // Handle array of memory objects
556
+ const items = Array.isArray(data) ? data : (data.memories ?? data.items ?? []);
557
+ for (let i = 0; i < items.length; i++) {
558
+ const item = items[i];
559
+ if (typeof item === 'string') {
560
+ entries.push(this.createMemoryEntry(item, `claude-memory:${basename(filePath)}`, new Date().toISOString(), i));
561
+ }
562
+ else if (item && typeof item === 'object') {
563
+ const text = item.content ?? item.text ?? item.memory ?? item.value ?? '';
564
+ if (text) {
565
+ entries.push({
566
+ id: item.id ?? item.uuid ?? `claude-memory-${i}`,
567
+ content: String(text),
568
+ source: `claude-memory:${basename(filePath)}`,
569
+ createdAt: item.created_at ?? item.createdAt ?? new Date().toISOString(),
570
+ updatedAt: item.updated_at ?? item.updatedAt ?? undefined,
571
+ metadata: { platform: 'claude.ai' },
572
+ });
573
+ }
574
+ }
575
+ }
576
+ return entries;
577
+ }
578
+ catch {
579
+ this.warnings.push(`Failed to parse memory JSON: ${basename(filePath)}`);
580
+ return [];
581
+ }
582
+ }
583
+ // ─── Private: Projects ────────────────────────────────────
584
+ async readProjects() {
585
+ if (!this.exportDir)
586
+ return [];
587
+ const projects = [];
588
+ // Look for projects/ directory in export
589
+ const projectsDirCandidates = ['projects', 'project'];
590
+ for (const dirName of projectsDirCandidates) {
591
+ const projectsDir = join(this.exportDir, dirName);
592
+ if (!existsSync(projectsDir))
593
+ continue;
594
+ const s = await stat(projectsDir).catch(() => null);
595
+ if (!s?.isDirectory())
596
+ continue;
597
+ const items = await readdir(projectsDir).catch(() => []);
598
+ for (const item of items) {
599
+ const itemPath = join(projectsDir, item);
600
+ const itemStat = await stat(itemPath).catch(() => null);
601
+ if (itemStat?.isDirectory()) {
602
+ // Each subdirectory is a project
603
+ const project = await this.parseProjectDir(itemPath, item);
604
+ if (project)
605
+ projects.push(project);
606
+ }
607
+ else if (item.endsWith('.json') && itemStat?.isFile()) {
608
+ // JSON file might be a project export
609
+ const project = await this.parseProjectJson(itemPath);
610
+ if (project)
611
+ projects.push(project);
612
+ }
613
+ }
614
+ }
615
+ return projects;
616
+ }
617
+ async parseProjectDir(dirPath, name) {
618
+ const project = {
619
+ name,
620
+ files: [],
621
+ };
622
+ // Look for instructions file
623
+ const instructionCandidates = [
624
+ 'instructions.md',
625
+ 'instructions.txt',
626
+ 'system_prompt.md',
627
+ 'system_prompt.txt',
628
+ 'prompt.md',
629
+ ];
630
+ for (const candidate of instructionCandidates) {
631
+ const filePath = join(dirPath, candidate);
632
+ if (existsSync(filePath)) {
633
+ const content = await this.safeReadFile(filePath);
634
+ if (content) {
635
+ project.instructions = content;
636
+ break;
637
+ }
638
+ }
639
+ }
640
+ // Look for project metadata
641
+ const metaPath = join(dirPath, 'project.json');
642
+ if (existsSync(metaPath)) {
643
+ const content = await this.safeReadFile(metaPath);
644
+ if (content) {
645
+ try {
646
+ const meta = JSON.parse(content);
647
+ project.uuid = meta.uuid;
648
+ project.description = meta.description;
649
+ project.created_at = meta.created_at;
650
+ project.updated_at = meta.updated_at;
651
+ if (meta.instructions && !project.instructions) {
652
+ project.instructions = meta.instructions;
653
+ }
654
+ }
655
+ catch {
656
+ this.warnings.push(`Failed to parse project metadata: ${name}`);
657
+ }
658
+ }
659
+ }
660
+ // Read uploaded files
661
+ const filesDirCandidates = ['files', 'documents', 'uploads'];
662
+ for (const filesDirName of filesDirCandidates) {
663
+ const filesDir = join(dirPath, filesDirName);
664
+ if (!existsSync(filesDir))
665
+ continue;
666
+ const files = await readdir(filesDir).catch(() => []);
667
+ for (const file of files) {
668
+ const filePath = join(filesDir, file);
669
+ const fileStat = await stat(filePath).catch(() => null);
670
+ if (!fileStat?.isFile())
671
+ continue;
672
+ const content = await this.safeReadFile(filePath);
673
+ if (content !== null) {
674
+ project.files?.push({
675
+ file_name: file,
676
+ content,
677
+ file_type: this.guessFileType(file),
678
+ file_size: fileStat.size,
679
+ });
680
+ }
681
+ }
682
+ }
683
+ // Also read any loose text files in the project dir itself
684
+ const items = await readdir(dirPath).catch(() => []);
685
+ for (const item of items) {
686
+ if (instructionCandidates.includes(item))
687
+ continue;
688
+ if (item === 'project.json')
689
+ continue;
690
+ if (filesDirCandidates.includes(item))
691
+ continue;
692
+ const itemPath = join(dirPath, item);
693
+ const itemStat = await stat(itemPath).catch(() => null);
694
+ if (!itemStat?.isFile())
695
+ continue;
696
+ const ext = extname(item).toLowerCase();
697
+ if (['.md', '.txt', '.csv', '.json', '.yaml', '.yml', '.xml'].includes(ext)) {
698
+ const content = await this.safeReadFile(itemPath);
699
+ if (content !== null) {
700
+ project.files?.push({
701
+ file_name: item,
702
+ content,
703
+ file_type: this.guessFileType(item),
704
+ file_size: itemStat.size,
705
+ });
706
+ }
707
+ }
708
+ }
709
+ // Only return project if it has any content
710
+ if (project.instructions || (project.files && project.files.length > 0)) {
711
+ return project;
712
+ }
713
+ return null;
714
+ }
715
+ async parseProjectJson(filePath) {
716
+ const content = await this.safeReadFile(filePath);
717
+ if (!content)
718
+ return null;
719
+ try {
720
+ const raw = JSON.parse(content);
721
+ if (raw.instructions || (raw.files && raw.files.length > 0) || raw.name) {
722
+ return {
723
+ uuid: raw.uuid,
724
+ name: raw.name ?? basename(filePath, '.json'),
725
+ description: raw.description,
726
+ instructions: raw.instructions,
727
+ created_at: raw.created_at,
728
+ updated_at: raw.updated_at,
729
+ files: raw.files ?? [],
730
+ };
731
+ }
732
+ }
733
+ catch {
734
+ this.warnings.push(`Failed to parse project file: ${basename(filePath)}`);
735
+ }
736
+ return null;
737
+ }
738
+ // ─── Private: Build Identity & Knowledge ──────────────────
739
+ /**
740
+ * Build personality string from project instructions.
741
+ * Concatenates all project instructions with separators.
742
+ */
743
+ buildPersonality(projects) {
744
+ if (projects.length === 0)
745
+ return '';
746
+ const parts = [];
747
+ for (const project of projects) {
748
+ if (!project.instructions)
749
+ continue;
750
+ const name = project.name ?? 'Unnamed Project';
751
+ parts.push(`--- Claude Project: ${name} ---\n${project.instructions}`);
752
+ }
753
+ return parts.join('\n\n');
754
+ }
755
+ /**
756
+ * Build knowledge documents from project uploaded files.
757
+ */
758
+ buildKnowledge(projects) {
759
+ const docs = [];
760
+ for (const project of projects) {
761
+ const projectName = project.name ?? 'unnamed';
762
+ if (!project.files)
763
+ continue;
764
+ for (const file of project.files) {
765
+ if (!file.content || !file.file_name)
766
+ continue;
767
+ const buf = Buffer.from(file.content, 'utf-8');
768
+ docs.push({
769
+ id: `project:${projectName}:${file.file_name}`,
770
+ filename: file.file_name,
771
+ mimeType: file.file_type ?? this.guessFileType(file.file_name),
772
+ path: `knowledge/projects/${projectName}/${file.file_name}`,
773
+ size: buf.length,
774
+ checksum: computeChecksum(buf),
775
+ });
776
+ }
777
+ }
778
+ return docs;
779
+ }
780
+ // ─── Private: Restore Guide Generation ────────────────────
781
+ generateRestoreGuide(snapshot) {
782
+ const lines = [];
783
+ lines.push('# Claude.ai Restore Guide');
784
+ lines.push('');
785
+ lines.push(`Generated: ${new Date().toISOString()}`);
786
+ lines.push(`Snapshot: ${snapshot.manifest.id}`);
787
+ lines.push(`Original export: ${snapshot.manifest.timestamp}`);
788
+ lines.push('');
789
+ lines.push('---');
790
+ lines.push('');
791
+ // Memories section
792
+ if (snapshot.memory.core.length > 0) {
793
+ lines.push('## 🧠 Memories');
794
+ lines.push('');
795
+ lines.push('Go to **Claude.ai → Settings → Memory** and add each memory:');
796
+ lines.push('');
797
+ for (const entry of snapshot.memory.core) {
798
+ lines.push(`- ${entry.content}`);
799
+ }
800
+ lines.push('');
801
+ lines.push('> **Tip:** You can tell Claude "Remember that..." for each item,');
802
+ lines.push('> or use the Memory settings to add them directly.');
803
+ lines.push('');
804
+ }
805
+ // Projects section
806
+ if (snapshot.identity.personality) {
807
+ lines.push('## 📁 Projects');
808
+ lines.push('');
809
+ lines.push('Create new Projects in Claude.ai with the following instructions:');
810
+ lines.push('');
811
+ lines.push('```markdown');
812
+ lines.push(snapshot.identity.personality);
813
+ lines.push('```');
814
+ lines.push('');
815
+ }
816
+ // Knowledge / uploaded files
817
+ if (snapshot.memory.knowledge.length > 0) {
818
+ lines.push('## 📄 Project Files');
819
+ lines.push('');
820
+ lines.push('Upload these files to the appropriate Claude Project:');
821
+ lines.push('');
822
+ for (const doc of snapshot.memory.knowledge) {
823
+ lines.push(`- **${doc.filename}** (${(doc.size / 1024).toFixed(1)} KB)`);
824
+ }
825
+ lines.push('');
826
+ }
827
+ // Conversations summary
828
+ if (snapshot.conversations.total > 0) {
829
+ lines.push('## 💬 Conversations');
830
+ lines.push('');
831
+ lines.push(`${snapshot.conversations.total} conversations were backed up. Conversations cannot be`);
832
+ lines.push('restored to Claude.ai but are preserved in the snapshot archive.');
833
+ lines.push('');
834
+ lines.push('Recent conversations:');
835
+ lines.push('');
836
+ const recent = [...snapshot.conversations.conversations]
837
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
838
+ .slice(0, 20);
839
+ for (const conv of recent) {
840
+ const title = conv.title ?? 'Untitled';
841
+ lines.push(`- **${title}** (${conv.messageCount} messages, ${conv.updatedAt.slice(0, 10)})`);
842
+ }
843
+ if (snapshot.conversations.total > 20) {
844
+ lines.push(`- ... and ${snapshot.conversations.total - 20} more`);
845
+ }
846
+ lines.push('');
847
+ }
848
+ lines.push('---');
849
+ lines.push('');
850
+ lines.push('*Generated by SaveState — https://github.com/nicholasgriffintn/savestate*');
851
+ return lines.join('\n');
852
+ }
853
+ formatMemoriesForRestore(memories) {
854
+ const lines = [];
855
+ lines.push('# Claude Memories to Import');
856
+ lines.push('');
857
+ lines.push('Copy each memory below into Claude.ai Settings → Memory,');
858
+ lines.push('or tell Claude "Remember that..." for each item.');
859
+ lines.push('');
860
+ for (let i = 0; i < memories.length; i++) {
861
+ lines.push(`${i + 1}. ${memories[i].content}`);
862
+ }
863
+ lines.push('');
864
+ return lines.join('\n');
865
+ }
866
+ // ─── Private: Utilities ───────────────────────────────────
867
+ async safeReadFile(filePath) {
868
+ try {
869
+ const s = await stat(filePath);
870
+ if (s.size > MAX_FILE_SIZE) {
871
+ this.warnings.push(`Skipped ${basename(filePath)} (${(s.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit)`);
872
+ return null;
873
+ }
874
+ return await readFile(filePath, 'utf-8');
875
+ }
876
+ catch {
877
+ return null;
878
+ }
879
+ }
880
+ guessFileType(filename) {
881
+ const ext = extname(filename).toLowerCase();
882
+ const mimeMap = {
883
+ '.md': 'text/markdown',
884
+ '.txt': 'text/plain',
885
+ '.csv': 'text/csv',
886
+ '.json': 'application/json',
887
+ '.yaml': 'text/yaml',
888
+ '.yml': 'text/yaml',
889
+ '.xml': 'application/xml',
890
+ '.html': 'text/html',
891
+ '.pdf': 'application/pdf',
892
+ '.py': 'text/x-python',
893
+ '.js': 'text/javascript',
894
+ '.ts': 'text/typescript',
895
+ '.rs': 'text/x-rust',
896
+ '.go': 'text/x-go',
897
+ '.rb': 'text/x-ruby',
898
+ '.sh': 'text/x-shellscript',
899
+ };
900
+ return mimeMap[ext] ?? 'text/plain';
901
+ }
902
+ }
903
+ //# sourceMappingURL=claude-web.js.map