@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,814 @@
1
+ /**
2
+ * Google Gemini Adapter
3
+ *
4
+ * Captures Gemini conversations, Gems (custom agents), and API data.
5
+ *
6
+ * Data sources:
7
+ * 1. Google Takeout export — conversations from "Gemini Apps" export
8
+ * 2. Gemini API — tuned models, cached content (requires API key)
9
+ *
10
+ * Environment variables:
11
+ * - SAVESTATE_GEMINI_EXPORT — path to Takeout export directory
12
+ * - GOOGLE_API_KEY or GEMINI_API_KEY — for API-based capture
13
+ *
14
+ * Uses native fetch() — no external dependencies.
15
+ */
16
+ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { join, basename } from 'node:path';
19
+ import { SAF_VERSION, generateSnapshotId, computeChecksum } from '../format.js';
20
+ // ─── Constants ───────────────────────────────────────────────
21
+ const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com';
22
+ const MAX_RETRIES = 3;
23
+ const INITIAL_BACKOFF_MS = 1000;
24
+ // ─── Adapter ─────────────────────────────────────────────────
25
+ export class GeminiAdapter {
26
+ id = 'gemini';
27
+ name = 'Google Gemini';
28
+ platform = 'gemini';
29
+ version = '0.1.0';
30
+ warnings = [];
31
+ exportDir = null;
32
+ apiKey = null;
33
+ constructor() {
34
+ this.apiKey = process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY ?? null;
35
+ }
36
+ async detect() {
37
+ // 1. Check env var pointing to Takeout directory
38
+ const envExport = process.env.SAVESTATE_GEMINI_EXPORT;
39
+ if (envExport && existsSync(envExport)) {
40
+ this.exportDir = await this.resolveExportDir(envExport);
41
+ if (this.exportDir)
42
+ return true;
43
+ }
44
+ // 2. Check for Gemini Apps/ or Takeout/Gemini Apps/ in current directory
45
+ const cwd = process.cwd();
46
+ const localPaths = [
47
+ join(cwd, 'Gemini Apps'),
48
+ join(cwd, 'Takeout', 'Gemini Apps'),
49
+ join(cwd, 'Google Gemini'),
50
+ join(cwd, 'Takeout', 'Google Gemini'),
51
+ ];
52
+ for (const p of localPaths) {
53
+ if (existsSync(p)) {
54
+ this.exportDir = p;
55
+ return true;
56
+ }
57
+ }
58
+ // 3. Check for .savestate/imports/gemini/
59
+ const importDir = join(cwd, '.savestate', 'imports', 'gemini');
60
+ if (existsSync(importDir)) {
61
+ this.exportDir = importDir;
62
+ return true;
63
+ }
64
+ // 4. Check for API key (can capture tuned models and cached content)
65
+ if (this.apiKey) {
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ async extract() {
71
+ this.warnings = [];
72
+ // Gather data from both sources
73
+ const conversations = await this.extractConversations();
74
+ const fullConversations = await this.extractFullConversations();
75
+ const gems = await this.extractGems();
76
+ const { tunedModels, cachedContent } = await this.extractApiData();
77
+ // Build personality from Gems
78
+ const personality = this.buildPersonality(gems);
79
+ // Build config from API data + Gems
80
+ const config = this.buildConfig(gems, tunedModels, cachedContent);
81
+ // Build memory entries from conversation metadata
82
+ const memoryEntries = [];
83
+ // Build knowledge docs
84
+ const knowledge = this.buildKnowledge(fullConversations);
85
+ const snapshotId = generateSnapshotId();
86
+ const now = new Date().toISOString();
87
+ // Summary reporting
88
+ const parts = [];
89
+ if (conversations.length > 0)
90
+ parts.push(`${conversations.length} conversations`);
91
+ if (gems.length > 0)
92
+ parts.push(`${gems.length} gems`);
93
+ if (tunedModels.length > 0)
94
+ parts.push(`${tunedModels.length} tuned models`);
95
+ if (cachedContent.length > 0)
96
+ parts.push(`${cachedContent.length} cached contents`);
97
+ if (parts.length > 0) {
98
+ console.log(` Found ${parts.join(', ')}`);
99
+ }
100
+ // Log warnings
101
+ if (this.warnings.length > 0) {
102
+ for (const w of this.warnings) {
103
+ console.warn(` ⚠ ${w}`);
104
+ }
105
+ }
106
+ const snapshot = {
107
+ manifest: {
108
+ version: SAF_VERSION,
109
+ timestamp: now,
110
+ id: snapshotId,
111
+ platform: this.platform,
112
+ adapter: this.id,
113
+ checksum: '',
114
+ size: 0,
115
+ },
116
+ identity: {
117
+ personality: personality || undefined,
118
+ config: Object.keys(config).length > 0 ? config : undefined,
119
+ tools: [],
120
+ },
121
+ memory: {
122
+ core: memoryEntries,
123
+ knowledge,
124
+ },
125
+ conversations: {
126
+ total: conversations.length,
127
+ conversations,
128
+ },
129
+ platform: await this.identify(),
130
+ chain: {
131
+ current: snapshotId,
132
+ ancestors: [],
133
+ },
134
+ restoreHints: this.buildRestoreHints(gems, tunedModels, cachedContent),
135
+ };
136
+ return snapshot;
137
+ }
138
+ async restore(snapshot) {
139
+ const outputDir = join(process.cwd(), '.savestate', 'restore', 'gemini');
140
+ await mkdir(outputDir, { recursive: true });
141
+ const guide = this.generateRestoreGuide(snapshot);
142
+ const guidePath = join(outputDir, 'gemini-restore-guide.md');
143
+ await writeFile(guidePath, guide, 'utf-8');
144
+ console.log(` 📄 Generated restore guide: ${guidePath}`);
145
+ // Restore conversations as individual JSON files
146
+ if (snapshot.conversations.total > 0) {
147
+ const convsDir = join(outputDir, 'conversations');
148
+ await mkdir(convsDir, { recursive: true });
149
+ // Write conversation index
150
+ const indexPath = join(convsDir, 'index.json');
151
+ await writeFile(indexPath, JSON.stringify(snapshot.conversations, null, 2), 'utf-8');
152
+ console.log(` 📋 Restored conversation index (${snapshot.conversations.total} conversations)`);
153
+ }
154
+ // Restore Gem configs
155
+ if (snapshot.identity.config) {
156
+ const gemConfigs = snapshot.identity.config.gems;
157
+ if (Array.isArray(gemConfigs) && gemConfigs.length > 0) {
158
+ const gemsDir = join(outputDir, 'gems');
159
+ await mkdir(gemsDir, { recursive: true });
160
+ for (const gem of gemConfigs) {
161
+ const filename = `${gem.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.json`;
162
+ await writeFile(join(gemsDir, filename), JSON.stringify(gem, null, 2), 'utf-8');
163
+ }
164
+ console.log(` 💎 Restored ${gemConfigs.length} Gem configurations`);
165
+ }
166
+ }
167
+ // If API key is available, try to restore cached content
168
+ if (this.apiKey && snapshot.identity.config) {
169
+ const cachedContent = snapshot.identity.config.cachedContent;
170
+ if (Array.isArray(cachedContent) && cachedContent.length > 0) {
171
+ console.log(` 📦 Found ${cachedContent.length} cached content entries (API restore not yet supported)`);
172
+ console.log(` See restore guide for manual steps.`);
173
+ }
174
+ }
175
+ console.log(`\n 📖 Review ${guidePath} for complete restore instructions.`);
176
+ }
177
+ async identify() {
178
+ const sources = [];
179
+ if (this.exportDir)
180
+ sources.push('takeout-export');
181
+ if (this.apiKey)
182
+ sources.push('api');
183
+ return {
184
+ name: 'Google Gemini',
185
+ version: 'unknown',
186
+ exportMethod: sources.join('+') || 'none',
187
+ };
188
+ }
189
+ // ─── Private: Detection helpers ───────────────────────────
190
+ /**
191
+ * Resolve the Takeout export directory.
192
+ * Handles various nesting patterns.
193
+ */
194
+ async resolveExportDir(basePath) {
195
+ // Direct Gemini Apps directory
196
+ if (await this.hasConversations(basePath))
197
+ return basePath;
198
+ // Nested under common patterns
199
+ const candidates = [
200
+ join(basePath, 'Gemini Apps'),
201
+ join(basePath, 'Google Gemini'),
202
+ join(basePath, 'Takeout', 'Gemini Apps'),
203
+ join(basePath, 'Takeout', 'Google Gemini'),
204
+ ];
205
+ for (const candidate of candidates) {
206
+ if (existsSync(candidate))
207
+ return candidate;
208
+ }
209
+ // Check if basePath itself contains JSON files (flat export)
210
+ try {
211
+ const files = await readdir(basePath);
212
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
213
+ if (jsonFiles.length > 0)
214
+ return basePath;
215
+ }
216
+ catch {
217
+ // not readable
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * Check if a directory has conversation-like content.
223
+ */
224
+ async hasConversations(dir) {
225
+ if (!existsSync(dir))
226
+ return false;
227
+ try {
228
+ const entries = await readdir(dir);
229
+ // Check for Conversations/ subdirectory or JSON files
230
+ if (entries.includes('Conversations'))
231
+ return true;
232
+ return entries.some(e => e.endsWith('.json'));
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ }
238
+ // ─── Private: Extraction ──────────────────────────────────
239
+ /**
240
+ * Extract conversation metadata from Takeout export.
241
+ */
242
+ async extractConversations() {
243
+ if (!this.exportDir)
244
+ return [];
245
+ const jsonFiles = await this.findConversationFiles();
246
+ const conversations = [];
247
+ for (const filePath of jsonFiles) {
248
+ try {
249
+ const raw = await readFile(filePath, 'utf-8');
250
+ const data = JSON.parse(raw);
251
+ const id = data.id ?? basename(filePath, '.json');
252
+ const title = data.title ?? this.inferTitle(data);
253
+ const messages = this.extractMessages(data);
254
+ const createdAt = data.create_time ?? this.inferTimestamp(filePath) ?? new Date().toISOString();
255
+ const updatedAt = data.update_time ?? (messages.length > 0 ? messages[messages.length - 1].timestamp : createdAt);
256
+ conversations.push({
257
+ id,
258
+ title,
259
+ createdAt,
260
+ updatedAt,
261
+ messageCount: messages.length,
262
+ path: `conversations/gemini/${basename(filePath)}`,
263
+ });
264
+ }
265
+ catch (err) {
266
+ this.warnings.push(`Failed to parse conversation: ${basename(filePath)} — ${err instanceof Error ? err.message : String(err)}`);
267
+ }
268
+ }
269
+ return conversations;
270
+ }
271
+ /**
272
+ * Extract full conversations for knowledge indexing.
273
+ */
274
+ async extractFullConversations() {
275
+ if (!this.exportDir)
276
+ return [];
277
+ const jsonFiles = await this.findConversationFiles();
278
+ const conversations = [];
279
+ for (const filePath of jsonFiles) {
280
+ try {
281
+ const raw = await readFile(filePath, 'utf-8');
282
+ const data = JSON.parse(raw);
283
+ const id = data.id ?? basename(filePath, '.json');
284
+ const title = data.title ?? this.inferTitle(data);
285
+ const messages = this.extractMessages(data);
286
+ const createdAt = data.create_time ?? this.inferTimestamp(filePath) ?? new Date().toISOString();
287
+ const updatedAt = data.update_time ?? (messages.length > 0 ? messages[messages.length - 1].timestamp : createdAt);
288
+ conversations.push({
289
+ id,
290
+ title,
291
+ createdAt,
292
+ updatedAt,
293
+ messages,
294
+ metadata: {
295
+ source: 'takeout',
296
+ filename: basename(filePath),
297
+ },
298
+ });
299
+ }
300
+ catch {
301
+ // Already warned in extractConversations
302
+ }
303
+ }
304
+ return conversations;
305
+ }
306
+ /**
307
+ * Find all conversation JSON files in the export directory.
308
+ */
309
+ async findConversationFiles() {
310
+ if (!this.exportDir)
311
+ return [];
312
+ const jsonFiles = [];
313
+ // Check Conversations/ subdirectory
314
+ const convsDir = join(this.exportDir, 'Conversations');
315
+ if (existsSync(convsDir)) {
316
+ await this.collectJsonFiles(convsDir, jsonFiles);
317
+ }
318
+ // Also check root of export dir for JSON files
319
+ await this.collectJsonFiles(this.exportDir, jsonFiles, false);
320
+ // Deduplicate by absolute path
321
+ return [...new Set(jsonFiles)];
322
+ }
323
+ /**
324
+ * Collect JSON files from a directory.
325
+ */
326
+ async collectJsonFiles(dir, results, recursive = true) {
327
+ try {
328
+ const entries = await readdir(dir, { withFileTypes: true });
329
+ for (const entry of entries) {
330
+ const fullPath = join(dir, entry.name);
331
+ if (entry.isFile() && entry.name.endsWith('.json')) {
332
+ // Skip non-conversation JSON files
333
+ if (entry.name === 'index.json' || entry.name === 'gems.json')
334
+ continue;
335
+ results.push(fullPath);
336
+ }
337
+ else if (recursive && entry.isDirectory() && entry.name !== 'Gems') {
338
+ await this.collectJsonFiles(fullPath, results, recursive);
339
+ }
340
+ }
341
+ }
342
+ catch {
343
+ // directory not readable
344
+ }
345
+ }
346
+ /**
347
+ * Extract messages from a Takeout conversation, handling format variations.
348
+ */
349
+ extractMessages(data) {
350
+ const messages = [];
351
+ // Try standard "messages" field
352
+ let rawMessages = data.messages;
353
+ // Try alternative field names
354
+ if (!rawMessages || !Array.isArray(rawMessages)) {
355
+ rawMessages = (data.conversation_messages ?? data.entries ?? data.turns);
356
+ }
357
+ if (!rawMessages || !Array.isArray(rawMessages))
358
+ return messages;
359
+ for (let i = 0; i < rawMessages.length; i++) {
360
+ const msg = rawMessages[i];
361
+ // Extract role — map Gemini's "model" to SaveState's "assistant"
362
+ let role = 'user';
363
+ const rawRole = (msg.role ?? msg.author ?? '');
364
+ if (rawRole === 'model' || rawRole === 'assistant' || rawRole === 'MODEL' || rawRole === 'ASSISTANT') {
365
+ role = 'assistant';
366
+ }
367
+ else if (rawRole === 'system' || rawRole === 'SYSTEM') {
368
+ role = 'system';
369
+ }
370
+ else if (rawRole === 'tool' || rawRole === 'function' || rawRole === 'TOOL') {
371
+ role = 'tool';
372
+ }
373
+ // Extract content — try multiple field names
374
+ const content = (msg.text ?? msg.content ?? msg.parts ?? '');
375
+ if (!content && typeof content !== 'string')
376
+ continue;
377
+ // Handle parts array (Gemini sometimes uses parts: [{text: "..."}])
378
+ let textContent;
379
+ if (Array.isArray(content)) {
380
+ textContent = content
381
+ .map((p) => {
382
+ if (typeof p === 'string')
383
+ return p;
384
+ if (p && typeof p === 'object' && 'text' in p)
385
+ return p.text;
386
+ return '';
387
+ })
388
+ .filter(Boolean)
389
+ .join('\n');
390
+ }
391
+ else {
392
+ textContent = String(content);
393
+ }
394
+ if (!textContent)
395
+ continue;
396
+ // Extract timestamp
397
+ const timestamp = msg.create_time ?? msg.timestamp ?? msg.created_at ?? new Date().toISOString();
398
+ // Build metadata
399
+ const metadata = {};
400
+ if (msg.metadata?.model_version) {
401
+ metadata.model = msg.metadata.model_version;
402
+ }
403
+ messages.push({
404
+ id: `msg-${i}`,
405
+ role,
406
+ content: textContent,
407
+ timestamp: String(timestamp),
408
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
409
+ });
410
+ }
411
+ return messages;
412
+ }
413
+ /**
414
+ * Extract Gem (custom agent) configurations from export.
415
+ */
416
+ async extractGems() {
417
+ if (!this.exportDir)
418
+ return [];
419
+ const gems = [];
420
+ // Check for gems.json in export directory
421
+ const gemsFiles = [
422
+ join(this.exportDir, 'gems.json'),
423
+ join(this.exportDir, 'Gems', 'gems.json'),
424
+ join(this.exportDir, 'Gems.json'),
425
+ ];
426
+ for (const gemFile of gemsFiles) {
427
+ if (existsSync(gemFile)) {
428
+ try {
429
+ const raw = await readFile(gemFile, 'utf-8');
430
+ const data = JSON.parse(raw);
431
+ if (Array.isArray(data)) {
432
+ gems.push(...data);
433
+ }
434
+ else if (data.gems && Array.isArray(data.gems)) {
435
+ gems.push(...data.gems);
436
+ }
437
+ }
438
+ catch (err) {
439
+ this.warnings.push(`Failed to parse gems file: ${basename(gemFile)} — ${err instanceof Error ? err.message : String(err)}`);
440
+ }
441
+ }
442
+ }
443
+ // Check for individual gem JSON files in a Gems/ directory
444
+ const gemsDir = join(this.exportDir, 'Gems');
445
+ if (existsSync(gemsDir)) {
446
+ try {
447
+ const files = await readdir(gemsDir);
448
+ for (const file of files) {
449
+ if (!file.endsWith('.json') || file === 'gems.json')
450
+ continue;
451
+ try {
452
+ const raw = await readFile(join(gemsDir, file), 'utf-8');
453
+ const gem = JSON.parse(raw);
454
+ if (gem.name || gem.instructions || gem.system_prompt) {
455
+ gems.push(gem);
456
+ }
457
+ }
458
+ catch {
459
+ this.warnings.push(`Failed to parse gem file: ${file}`);
460
+ }
461
+ }
462
+ }
463
+ catch {
464
+ // Gems directory not readable
465
+ }
466
+ }
467
+ return gems;
468
+ }
469
+ /**
470
+ * Extract data from Gemini API (tuned models, cached content).
471
+ */
472
+ async extractApiData() {
473
+ if (!this.apiKey) {
474
+ return { tunedModels: [], cachedContent: [] };
475
+ }
476
+ const tunedModels = await this.fetchTunedModels();
477
+ const cachedContent = await this.fetchCachedContent();
478
+ return { tunedModels, cachedContent };
479
+ }
480
+ /**
481
+ * Fetch tuned models from the Gemini API.
482
+ */
483
+ async fetchTunedModels() {
484
+ const models = [];
485
+ let pageToken;
486
+ try {
487
+ do {
488
+ const url = new URL(`${GEMINI_API_BASE}/v1beta/tunedModels`);
489
+ url.searchParams.set('key', this.apiKey);
490
+ if (pageToken)
491
+ url.searchParams.set('pageToken', pageToken);
492
+ const data = await this.apiFetch(url.toString());
493
+ if (data?.tunedModels) {
494
+ models.push(...data.tunedModels);
495
+ }
496
+ pageToken = data?.nextPageToken;
497
+ } while (pageToken);
498
+ }
499
+ catch (err) {
500
+ this.warnings.push(`Failed to fetch tuned models: ${err instanceof Error ? err.message : String(err)}`);
501
+ }
502
+ return models;
503
+ }
504
+ /**
505
+ * Fetch cached content from the Gemini API.
506
+ */
507
+ async fetchCachedContent() {
508
+ const contents = [];
509
+ let pageToken;
510
+ try {
511
+ do {
512
+ const url = new URL(`${GEMINI_API_BASE}/v1beta/cachedContents`);
513
+ url.searchParams.set('key', this.apiKey);
514
+ if (pageToken)
515
+ url.searchParams.set('pageToken', pageToken);
516
+ const data = await this.apiFetch(url.toString());
517
+ if (data?.cachedContents) {
518
+ contents.push(...data.cachedContents);
519
+ }
520
+ pageToken = data?.nextPageToken;
521
+ } while (pageToken);
522
+ }
523
+ catch (err) {
524
+ this.warnings.push(`Failed to fetch cached content: ${err instanceof Error ? err.message : String(err)}`);
525
+ }
526
+ return contents;
527
+ }
528
+ // ─── Private: Building snapshot components ────────────────
529
+ /**
530
+ * Build personality string from Gem configurations.
531
+ */
532
+ buildPersonality(gems) {
533
+ if (gems.length === 0)
534
+ return '';
535
+ const parts = [];
536
+ for (const gem of gems) {
537
+ const instruction = gem.instructions ?? gem.system_prompt ?? '';
538
+ if (!instruction && !gem.description)
539
+ continue;
540
+ const header = `--- Gem: ${gem.name} ---`;
541
+ const desc = gem.description ? `Description: ${gem.description}\n` : '';
542
+ const model = gem.model ? `Model: ${gem.model}\n` : '';
543
+ const body = instruction ? `\n${instruction}` : '';
544
+ parts.push(`${header}\n${desc}${model}${body}`);
545
+ }
546
+ return parts.join('\n\n');
547
+ }
548
+ /**
549
+ * Build config object from gems, tuned models, and cached content.
550
+ */
551
+ buildConfig(gems, tunedModels, cachedContent) {
552
+ const config = {};
553
+ if (gems.length > 0) {
554
+ config.gems = gems;
555
+ }
556
+ if (tunedModels.length > 0) {
557
+ config.tunedModels = tunedModels.map(m => ({
558
+ name: m.name,
559
+ displayName: m.displayName,
560
+ description: m.description,
561
+ state: m.state,
562
+ baseModel: m.baseModel,
563
+ temperature: m.temperature,
564
+ topP: m.topP,
565
+ topK: m.topK,
566
+ createTime: m.createTime,
567
+ updateTime: m.updateTime,
568
+ hyperparameters: m.tuningTask?.hyperparameters,
569
+ }));
570
+ }
571
+ if (cachedContent.length > 0) {
572
+ config.cachedContent = cachedContent.map(c => ({
573
+ name: c.name,
574
+ displayName: c.displayName,
575
+ model: c.model,
576
+ createTime: c.createTime,
577
+ updateTime: c.updateTime,
578
+ expireTime: c.expireTime,
579
+ totalTokenCount: c.usageMetadata?.totalTokenCount,
580
+ }));
581
+ }
582
+ // Note data source info
583
+ config._dataSources = {
584
+ takeoutExport: !!this.exportDir,
585
+ apiKey: !!this.apiKey,
586
+ exportDir: this.exportDir ?? undefined,
587
+ };
588
+ return config;
589
+ }
590
+ /**
591
+ * Build knowledge documents from conversations.
592
+ */
593
+ buildKnowledge(conversations) {
594
+ const docs = [];
595
+ for (const conv of conversations) {
596
+ const content = JSON.stringify(conv, null, 2);
597
+ const buf = Buffer.from(content, 'utf-8');
598
+ docs.push({
599
+ id: `conversation:${conv.id}`,
600
+ filename: `conversations/gemini/${conv.id}.json`,
601
+ mimeType: 'application/json',
602
+ path: `knowledge/conversations/gemini/${conv.id}.json`,
603
+ size: buf.length,
604
+ checksum: computeChecksum(buf),
605
+ });
606
+ }
607
+ return docs;
608
+ }
609
+ /**
610
+ * Build restore hints based on available data.
611
+ */
612
+ buildRestoreHints(gems, tunedModels, cachedContent) {
613
+ const steps = [];
614
+ const manualSteps = [];
615
+ // Always generate the restore guide
616
+ steps.push({
617
+ type: 'file',
618
+ description: 'Generate gemini-restore-guide.md with organized data for manual import',
619
+ target: '.savestate/restore/gemini/',
620
+ });
621
+ if (gems.length > 0) {
622
+ steps.push({
623
+ type: 'manual',
624
+ description: `Recreate ${gems.length} Gem(s) in Google Gemini with saved instructions`,
625
+ target: 'https://gemini.google.com/gems',
626
+ });
627
+ manualSteps.push('Open https://gemini.google.com/gems and manually recreate each Gem', 'Gem instructions are saved in the restore guide and individual JSON files');
628
+ }
629
+ if (tunedModels.length > 0) {
630
+ manualSteps.push(`${tunedModels.length} tuned model(s) detected — configurations saved but training data must be re-supplied`, 'Use Google AI Studio (aistudio.google.com) or the Gemini API to recreate tuned models');
631
+ }
632
+ if (cachedContent.length > 0) {
633
+ if (this.apiKey) {
634
+ steps.push({
635
+ type: 'api',
636
+ description: `Restore ${cachedContent.length} cached content entry/entries via Gemini API`,
637
+ target: 'generativelanguage.googleapis.com/v1beta/cachedContents',
638
+ });
639
+ }
640
+ manualSteps.push('Cached content configurations saved — content itself must be re-uploaded');
641
+ }
642
+ manualSteps.push('Gemini conversations are read-only exports — they cannot be re-imported', 'Review gemini-restore-guide.md for complete instructions');
643
+ return {
644
+ platform: 'gemini',
645
+ steps,
646
+ manualSteps,
647
+ };
648
+ }
649
+ // ─── Private: Restore helpers ─────────────────────────────
650
+ /**
651
+ * Generate a comprehensive restore guide.
652
+ */
653
+ generateRestoreGuide(snapshot) {
654
+ const lines = [];
655
+ lines.push('# Gemini Restore Guide');
656
+ lines.push('');
657
+ lines.push(`Generated: ${new Date().toISOString()}`);
658
+ lines.push(`Snapshot: ${snapshot.manifest.id}`);
659
+ lines.push(`Platform: ${snapshot.platform.name}`);
660
+ lines.push(`Export method: ${snapshot.platform.exportMethod}`);
661
+ lines.push('');
662
+ // Gems section
663
+ const gems = snapshot.identity.config?.gems;
664
+ if (gems && gems.length > 0) {
665
+ lines.push('## 💎 Gems (Custom Agents)');
666
+ lines.push('');
667
+ lines.push('To restore your Gems, go to https://gemini.google.com/gems and create each one:');
668
+ lines.push('');
669
+ for (const gem of gems) {
670
+ lines.push(`### ${gem.name}`);
671
+ if (gem.description)
672
+ lines.push(`**Description:** ${gem.description}`);
673
+ if (gem.model)
674
+ lines.push(`**Model:** ${gem.model}`);
675
+ lines.push('');
676
+ const instructions = gem.instructions ?? gem.system_prompt;
677
+ if (instructions) {
678
+ lines.push('**Instructions:**');
679
+ lines.push('```');
680
+ lines.push(instructions);
681
+ lines.push('```');
682
+ lines.push('');
683
+ }
684
+ }
685
+ }
686
+ // Conversations section
687
+ if (snapshot.conversations.total > 0) {
688
+ lines.push('## 💬 Conversations');
689
+ lines.push('');
690
+ lines.push(`Total: ${snapshot.conversations.total} conversations`);
691
+ lines.push('');
692
+ lines.push('> Note: Gemini conversations cannot be re-imported. They are preserved here as a record.');
693
+ lines.push('');
694
+ for (const conv of snapshot.conversations.conversations.slice(0, 50)) {
695
+ const date = conv.createdAt.split('T')[0];
696
+ lines.push(`- **${conv.title ?? conv.id}** (${date}) — ${conv.messageCount} messages`);
697
+ }
698
+ if (snapshot.conversations.total > 50) {
699
+ lines.push(`- ... and ${snapshot.conversations.total - 50} more`);
700
+ }
701
+ lines.push('');
702
+ }
703
+ // Tuned models section
704
+ const tunedModels = snapshot.identity.config?.tunedModels;
705
+ if (tunedModels && tunedModels.length > 0) {
706
+ lines.push('## 🧪 Tuned Models');
707
+ lines.push('');
708
+ lines.push('To recreate tuned models, use Google AI Studio (aistudio.google.com) or the Gemini API.');
709
+ lines.push('');
710
+ for (const model of tunedModels) {
711
+ lines.push(`### ${model.displayName ?? model.name}`);
712
+ if (model.description)
713
+ lines.push(`**Description:** ${model.description}`);
714
+ if (model.baseModel)
715
+ lines.push(`**Base Model:** ${model.baseModel}`);
716
+ if (model.state)
717
+ lines.push(`**State:** ${model.state}`);
718
+ if (model.temperature !== undefined)
719
+ lines.push(`**Temperature:** ${model.temperature}`);
720
+ if (model.hyperparameters) {
721
+ lines.push(`**Hyperparameters:** \`${JSON.stringify(model.hyperparameters)}\``);
722
+ }
723
+ lines.push('');
724
+ }
725
+ }
726
+ // Cached content section
727
+ const cachedContent = snapshot.identity.config?.cachedContent;
728
+ if (cachedContent && cachedContent.length > 0) {
729
+ lines.push('## 📦 Cached Content');
730
+ lines.push('');
731
+ lines.push('Cached content entries (content must be re-uploaded):');
732
+ lines.push('');
733
+ for (const entry of cachedContent) {
734
+ lines.push(`- **${entry.displayName ?? entry.name}** — Model: ${entry.model ?? 'unknown'}, Tokens: ${entry.totalTokenCount ?? 'unknown'}`);
735
+ }
736
+ lines.push('');
737
+ }
738
+ // Manual steps
739
+ if (snapshot.restoreHints.manualSteps && snapshot.restoreHints.manualSteps.length > 0) {
740
+ lines.push('## 📋 Manual Steps Required');
741
+ lines.push('');
742
+ for (const step of snapshot.restoreHints.manualSteps) {
743
+ lines.push(`- [ ] ${step}`);
744
+ }
745
+ lines.push('');
746
+ }
747
+ return lines.join('\n');
748
+ }
749
+ // ─── Private: Utility ─────────────────────────────────────
750
+ /**
751
+ * Infer a title from the conversation data when title field is missing.
752
+ */
753
+ inferTitle(data) {
754
+ // Try to get the first user message as a title
755
+ const messages = (data.messages ?? data.conversation_messages ?? data.entries ?? data.turns);
756
+ if (messages && Array.isArray(messages) && messages.length > 0) {
757
+ const firstMsg = messages[0];
758
+ const text = (firstMsg.text ?? firstMsg.content ?? '');
759
+ if (typeof text === 'string' && text.length > 0) {
760
+ return text.length > 80 ? text.slice(0, 77) + '...' : text;
761
+ }
762
+ }
763
+ return 'Untitled Conversation';
764
+ }
765
+ /**
766
+ * Infer a timestamp from a filename like "2025-01-15T10_30_00-conversation.json".
767
+ */
768
+ inferTimestamp(filePath) {
769
+ const name = basename(filePath, '.json');
770
+ // Match ISO-like timestamp with underscores instead of colons
771
+ const match = name.match(/(\d{4}-\d{2}-\d{2}T\d{2}[_:]\d{2}[_:]\d{2})/);
772
+ if (match) {
773
+ return match[1].replace(/_/g, ':') + 'Z';
774
+ }
775
+ return null;
776
+ }
777
+ /**
778
+ * Make an API request with retries and exponential backoff.
779
+ */
780
+ async apiFetch(url, options) {
781
+ let lastError = null;
782
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
783
+ try {
784
+ const response = await fetch(url, {
785
+ ...options,
786
+ headers: {
787
+ 'Content-Type': 'application/json',
788
+ ...options?.headers,
789
+ },
790
+ });
791
+ if (response.status === 429) {
792
+ // Rate limited — backoff and retry
793
+ const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
794
+ await new Promise(resolve => setTimeout(resolve, backoff));
795
+ continue;
796
+ }
797
+ if (!response.ok) {
798
+ const body = await response.text().catch(() => '');
799
+ throw new Error(`API error ${response.status}: ${body.slice(0, 200)}`);
800
+ }
801
+ return (await response.json());
802
+ }
803
+ catch (err) {
804
+ lastError = err instanceof Error ? err : new Error(String(err));
805
+ if (attempt < MAX_RETRIES - 1) {
806
+ const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
807
+ await new Promise(resolve => setTimeout(resolve, backoff));
808
+ }
809
+ }
810
+ }
811
+ throw lastError ?? new Error('API request failed after retries');
812
+ }
813
+ }
814
+ //# sourceMappingURL=gemini.js.map