@savestate/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -12
- package/dist/adapters/chatgpt.d.ts +113 -0
- package/dist/adapters/chatgpt.d.ts.map +1 -0
- package/dist/adapters/chatgpt.js +668 -0
- package/dist/adapters/chatgpt.js.map +1 -0
- package/dist/adapters/claude-web.d.ts +81 -0
- package/dist/adapters/claude-web.d.ts.map +1 -0
- package/dist/adapters/claude-web.js +903 -0
- package/dist/adapters/claude-web.js.map +1 -0
- package/dist/adapters/gemini.d.ts +108 -0
- package/dist/adapters/gemini.d.ts.map +1 -0
- package/dist/adapters/gemini.js +814 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/openai-assistants.d.ts +79 -36
- package/dist/adapters/openai-assistants.d.ts.map +1 -1
- package/dist/adapters/openai-assistants.js +802 -78
- package/dist/adapters/openai-assistants.js.map +1 -1
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +6 -4
- package/dist/adapters/registry.js.map +1 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -10
- package/dist/cli.js.map +1 -1
- package/dist/commands/adapters.js +5 -5
- package/dist/commands/adapters.js.map +1 -1
- package/dist/commands/index.d.ts +0 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +0 -1
- package/dist/commands/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,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
|