@savestate/cli 0.1.1 → 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.
@@ -1,50 +1,33 @@
1
1
  /**
2
- * OpenAI Assistants Adapter (Stub)
2
+ * OpenAI Assistants Adapter
3
3
  *
4
- * Future adapter for OpenAI Assistants API.
5
- * This is a placeholder that documents what the adapter would capture
6
- * and provides detection logic.
4
+ * Full adapter for OpenAI Assistants API v2.
5
+ * Captures assistant configuration, files, vector stores, and threads.
7
6
  *
8
- * When implemented, this adapter would capture:
7
+ * Authentication: OPENAI_API_KEY env var (required).
8
+ * Target assistant: SAVESTATE_OPENAI_ASSISTANT_ID env var (optional).
9
9
  *
10
- * - **Assistant configuration**instructions, model, name, description,
11
- * tools (code_interpreter, file_search, function definitions), metadata,
12
- * temperature, top_p, response_format
13
- *
14
- * - **Threads and messages** — conversation threads with full message history,
15
- * including role, content (text + annotations), attachments, and run metadata.
16
- * Would support paginated retrieval for large thread histories.
17
- *
18
- * - **Files attached to the assistant** — files uploaded for code_interpreter
19
- * or file_search. Would capture file metadata (id, filename, purpose, bytes)
20
- * and optionally download file content.
21
- *
22
- * - **Vector store contents** — vector stores used by file_search tool,
23
- * including store configuration, file associations, and chunking strategy.
24
- * Would NOT re-embed on restore; instead store file references.
25
- *
26
- * - **Function/tool definitions** — full JSON schemas for any custom function
27
- * tools defined on the assistant, enabling restore to a new assistant.
28
- *
29
- * Authentication: Uses OPENAI_API_KEY env var or .openai/ config directory.
30
- *
31
- * API endpoints used:
32
- * GET /v1/assistants/{id} — Assistant config
33
- * GET /v1/assistants/{id}/files — Attached files
34
- * GET /v1/threads/{id}/messages — Thread messages
35
- * GET /v1/files/{id}/content — File download
36
- * GET /v1/vector_stores/{id} — Vector store config
37
- * POST /v1/assistants — Create assistant (restore)
38
- * POST /v1/threads — Create thread (restore)
10
+ * Uses native fetch()no openai npm package dependency.
39
11
  */
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
40
13
  import { existsSync } from 'node:fs';
41
14
  import { join } from 'node:path';
42
15
  import { homedir } from 'node:os';
16
+ import { SAF_VERSION, generateSnapshotId, computeChecksum } from '../format.js';
17
+ // ─── Constants ───────────────────────────────────────────────
18
+ const OPENAI_BASE = 'https://api.openai.com/v1';
19
+ const THREADS_CACHE_DIR = '.savestate';
20
+ const THREADS_CACHE_FILE = 'openai-threads.json';
21
+ const MAX_RETRIES = 3;
22
+ const INITIAL_BACKOFF_MS = 1000;
23
+ // ─── Adapter ─────────────────────────────────────────────────
43
24
  export class OpenAIAssistantsAdapter {
44
25
  id = 'openai-assistants';
45
26
  name = 'OpenAI Assistants';
46
27
  platform = 'openai-assistants';
47
- version = '0.0.1';
28
+ version = '0.1.0';
29
+ apiKey = '';
30
+ warnings = [];
48
31
  async detect() {
49
32
  // Check for .openai/ config directory
50
33
  const openaiDir = join(homedir(), '.openai');
@@ -59,56 +42,797 @@ export class OpenAIAssistantsAdapter {
59
42
  return false;
60
43
  }
61
44
  async extract() {
62
- // TODO: Implement full extraction via OpenAI API
63
- //
64
- // Steps:
65
- // 1. List assistants via GET /v1/assistants
66
- // 2. For each assistant:
67
- // a. Capture config (instructions, model, tools, metadata)
68
- // b. List attached files
69
- // c. List vector stores
70
- // d. Optionally list threads (requires thread IDs — may need local cache)
71
- // 3. Package into Snapshot format:
72
- // - identity.personality = assistant.instructions
73
- // - identity.config = { model, tools, metadata, response_format, ... }
74
- // - identity.tools = function tool definitions
75
- // - memory.core = thread messages (if thread IDs known)
76
- // - memory.knowledge = file attachments
77
- // - conversations = threads index
78
- //
79
- // Challenge: OpenAI doesn't provide a "list all threads" endpoint.
80
- // Threads must be tracked locally or discovered via other means.
81
- // Consider storing thread IDs in .openai/threads.json for tracking.
82
- throw new Error('OpenAI Assistants adapter is not yet implemented.\n' +
83
- 'This is a planned adapter contributions welcome!\n' +
84
- 'See: https://platform.openai.com/docs/api-reference/assistants');
85
- }
86
- async restore(_snapshot) {
87
- // TODO: Implement restore via OpenAI API
88
- //
89
- // Steps:
90
- // 1. Create a new assistant via POST /v1/assistants with:
91
- // - instructions from identity.personality
92
- // - model from identity.config
93
- // - tool definitions from identity.tools
94
- // 2. Upload files and attach to assistant
95
- // 3. Create vector stores and add files
96
- // 4. Optionally recreate threads with messages
97
- //
98
- // Restore strategy:
99
- // - Always create NEW assistant (don't overwrite existing)
100
- // - Return new assistant ID for user to verify
101
- // - Warn about any tools/features that couldn't be restored
102
- throw new Error('OpenAI Assistants adapter restore is not yet implemented.\n' +
103
- 'This is a planned adapter — contributions welcome!');
45
+ this.apiKey = this.requireApiKey();
46
+ this.warnings = [];
47
+ // 1. Determine which assistant to extract
48
+ const assistant = await this.resolveAssistant();
49
+ this.log(`Extracting assistant: ${assistant.name ?? assistant.id} (${assistant.model})`);
50
+ // 2. Extract identity (config, instructions, tools)
51
+ this.log('Extracting assistant configuration...');
52
+ const { personality, config, tools } = this.extractIdentity(assistant);
53
+ // 3. Extract files from tool_resources
54
+ this.log('Extracting files and vector stores...');
55
+ const { knowledge, fileContents, vectorStores } = await this.extractFiles(assistant);
56
+ // Store vector store info in config
57
+ if (vectorStores.length > 0) {
58
+ config.vector_stores = vectorStores;
59
+ }
60
+ // Store file contents in config for restore
61
+ if (Object.keys(fileContents).length > 0) {
62
+ config._file_contents = fileContents;
63
+ }
64
+ // 4. Extract threads (if we have cached thread IDs)
65
+ this.log('Checking for thread history...');
66
+ const { conversations, conversationDetails } = await this.extractThreads(assistant.id);
67
+ // 5. Build memory entries from file metadata
68
+ const memoryEntries = [];
69
+ // Build snapshot
70
+ const snapshotId = generateSnapshotId();
71
+ const now = new Date().toISOString();
72
+ if (this.warnings.length > 0) {
73
+ for (const w of this.warnings) {
74
+ console.warn(` ⚠ ${w}`);
75
+ }
76
+ }
77
+ const snapshot = {
78
+ manifest: {
79
+ version: SAF_VERSION,
80
+ timestamp: now,
81
+ id: snapshotId,
82
+ platform: this.platform,
83
+ adapter: this.id,
84
+ checksum: '',
85
+ size: 0,
86
+ },
87
+ identity: {
88
+ personality,
89
+ config,
90
+ tools,
91
+ },
92
+ memory: {
93
+ core: memoryEntries,
94
+ knowledge,
95
+ },
96
+ conversations: {
97
+ total: conversations.length,
98
+ conversations,
99
+ },
100
+ platform: await this.identify(),
101
+ chain: {
102
+ current: snapshotId,
103
+ ancestors: [],
104
+ },
105
+ restoreHints: {
106
+ platform: this.platform,
107
+ steps: [
108
+ {
109
+ type: 'api',
110
+ description: 'Create new assistant with captured configuration',
111
+ target: 'POST /v1/assistants',
112
+ },
113
+ {
114
+ type: 'api',
115
+ description: 'Upload files and create vector stores',
116
+ target: 'POST /v1/files, POST /v1/vector_stores',
117
+ },
118
+ {
119
+ type: 'api',
120
+ description: 'Recreate threads with messages (optional)',
121
+ target: 'POST /v1/threads, POST /v1/threads/{id}/messages',
122
+ },
123
+ ],
124
+ manualSteps: [
125
+ 'Verify the new assistant ID works in your application',
126
+ 'Update any thread IDs in your application (threads cannot be migrated, only recreated)',
127
+ 'Vector store files will be re-indexed automatically after upload',
128
+ ],
129
+ },
130
+ };
131
+ this.log(`✓ Extraction complete: ${assistant.name ?? assistant.id}`);
132
+ return snapshot;
133
+ }
134
+ async restore(snapshot) {
135
+ this.apiKey = this.requireApiKey();
136
+ this.warnings = [];
137
+ const config = (snapshot.identity.config ?? {});
138
+ // 1. Upload files first (if we have file contents)
139
+ const fileIdMap = new Map(); // old ID → new ID
140
+ const fileContents = (config._file_contents ?? {});
141
+ if (Object.keys(fileContents).length > 0) {
142
+ this.log(`Uploading ${Object.keys(fileContents).length} files...`);
143
+ for (const [oldFileId, b64content] of Object.entries(fileContents)) {
144
+ const fileInfo = snapshot.memory.knowledge.find(k => k.id === `openai-file:${oldFileId}`);
145
+ const filename = fileInfo?.filename ?? `file-${oldFileId}`;
146
+ try {
147
+ const newFileId = await this.uploadFile(filename, Buffer.from(b64content, 'base64'));
148
+ fileIdMap.set(oldFileId, newFileId);
149
+ this.log(` Uploaded: ${filename} → ${newFileId}`);
150
+ }
151
+ catch (err) {
152
+ this.warn(`Failed to upload file ${filename}: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ }
155
+ }
156
+ // 2. Create vector stores (if any)
157
+ const vectorStoreIdMap = new Map(); // old ID → new ID
158
+ const vectorStores = (config.vector_stores ?? []);
159
+ if (vectorStores.length > 0) {
160
+ this.log(`Creating ${vectorStores.length} vector store(s)...`);
161
+ for (const vs of vectorStores) {
162
+ try {
163
+ // Map old file IDs to new ones
164
+ const newFileIds = vs.file_ids
165
+ .map(fid => fileIdMap.get(fid))
166
+ .filter((id) => id !== undefined);
167
+ const newVsId = await this.createVectorStore(vs.name, newFileIds, vs.metadata, vs.expires_after);
168
+ vectorStoreIdMap.set(vs.id, newVsId);
169
+ this.log(` Created vector store: ${vs.name} → ${newVsId} (${newFileIds.length} files)`);
170
+ }
171
+ catch (err) {
172
+ this.warn(`Failed to create vector store ${vs.name}: ${err instanceof Error ? err.message : String(err)}`);
173
+ }
174
+ }
175
+ }
176
+ // 3. Build tool_resources for the new assistant
177
+ const toolResources = {};
178
+ // Code interpreter files
179
+ const codeInterpreterFileIds = (config.tool_resources
180
+ ?.code_interpreter?.file_ids ?? [])
181
+ .map(fid => fileIdMap.get(fid))
182
+ .filter((id) => id !== undefined);
183
+ if (codeInterpreterFileIds.length > 0) {
184
+ toolResources.code_interpreter = { file_ids: codeInterpreterFileIds };
185
+ }
186
+ // File search vector stores
187
+ const oldVsIds = config.tool_resources
188
+ ?.file_search?.vector_store_ids ?? [];
189
+ const newVsIds = oldVsIds
190
+ .map(vid => vectorStoreIdMap.get(vid))
191
+ .filter((id) => id !== undefined);
192
+ if (newVsIds.length > 0) {
193
+ toolResources.file_search = { vector_store_ids: newVsIds };
194
+ }
195
+ // 4. Create the new assistant
196
+ this.log('Creating new assistant...');
197
+ const assistantBody = {
198
+ model: config.model ?? 'gpt-4o',
199
+ name: config.name ?? undefined,
200
+ description: config.description ?? undefined,
201
+ instructions: snapshot.identity.personality ?? undefined,
202
+ metadata: config.metadata ?? {},
203
+ };
204
+ // Add temperature/top_p if set
205
+ if (config.temperature !== undefined && config.temperature !== null) {
206
+ assistantBody.temperature = config.temperature;
207
+ }
208
+ if (config.top_p !== undefined && config.top_p !== null) {
209
+ assistantBody.top_p = config.top_p;
210
+ }
211
+ if (config.response_format !== undefined && config.response_format !== null) {
212
+ assistantBody.response_format = config.response_format;
213
+ }
214
+ // Restore tools
215
+ const restoredTools = [];
216
+ const originalTools = (config.tools_raw ?? []);
217
+ for (const tool of originalTools) {
218
+ restoredTools.push(tool);
219
+ }
220
+ if (restoredTools.length > 0) {
221
+ assistantBody.tools = restoredTools;
222
+ }
223
+ // Attach tool_resources
224
+ if (Object.keys(toolResources).length > 0) {
225
+ assistantBody.tool_resources = toolResources;
226
+ }
227
+ // Remove undefined values
228
+ for (const key of Object.keys(assistantBody)) {
229
+ if (assistantBody[key] === undefined) {
230
+ delete assistantBody[key];
231
+ }
232
+ }
233
+ const newAssistant = await this.apiPost('/assistants', assistantBody);
234
+ this.log(`✓ Created assistant: ${newAssistant.name ?? '(unnamed)'} → ${newAssistant.id}`);
235
+ // 5. Optionally recreate threads
236
+ if (snapshot.conversations.total > 0) {
237
+ this.log(`Recreating ${snapshot.conversations.total} thread(s)...`);
238
+ const newThreadIds = [];
239
+ for (const convMeta of snapshot.conversations.conversations) {
240
+ try {
241
+ // The conversation details are stored as memory entries
242
+ const convEntry = snapshot.memory.core.find(m => m.id === `thread:${convMeta.id}`);
243
+ if (!convEntry) {
244
+ this.warn(`No message data for thread ${convMeta.id}, skipping`);
245
+ continue;
246
+ }
247
+ const messages = JSON.parse(convEntry.content);
248
+ const threadId = await this.recreateThread(messages);
249
+ newThreadIds.push(threadId);
250
+ this.log(` Recreated thread: ${convMeta.id} → ${threadId} (${messages.length} messages)`);
251
+ }
252
+ catch (err) {
253
+ this.warn(`Failed to recreate thread ${convMeta.id}: ${err instanceof Error ? err.message : String(err)}`);
254
+ }
255
+ }
256
+ // Save new thread IDs to cache
257
+ if (newThreadIds.length > 0) {
258
+ await this.saveThreadCache(newAssistant.id, newThreadIds);
259
+ }
260
+ }
261
+ // 6. Summary
262
+ console.error('');
263
+ console.error('┌─────────────────────────────────────────────┐');
264
+ console.error('│ ✓ Restore complete │');
265
+ console.error('├─────────────────────────────────────────────┤');
266
+ console.error(`│ New Assistant ID: ${newAssistant.id} │`);
267
+ console.error(`│ Model: ${(newAssistant.model ?? '').padEnd(35)}│`);
268
+ console.error(`│ Tools: ${String(newAssistant.tools?.length ?? 0).padEnd(35)}│`);
269
+ console.error(`│ Files uploaded: ${String(fileIdMap.size).padEnd(27)}│`);
270
+ console.error(`│ Vector stores: ${String(vectorStoreIdMap.size).padEnd(27)}│`);
271
+ console.error('└─────────────────────────────────────────────┘');
272
+ if (this.warnings.length > 0) {
273
+ console.error('');
274
+ for (const w of this.warnings) {
275
+ console.warn(` ⚠ ${w}`);
276
+ }
277
+ }
104
278
  }
105
279
  async identify() {
106
280
  return {
107
281
  name: 'OpenAI Assistants',
108
282
  version: this.version,
109
- apiVersion: '2024-01',
283
+ apiVersion: 'v2',
110
284
  exportMethod: 'api',
111
285
  };
112
286
  }
287
+ // ─── Private: API helpers ──────────────────────────────────
288
+ requireApiKey() {
289
+ const key = process.env.OPENAI_API_KEY;
290
+ if (!key) {
291
+ throw new Error('OPENAI_API_KEY environment variable is required.\n' +
292
+ 'Set it with: export OPENAI_API_KEY=sk-...\n' +
293
+ 'Get your key at: https://platform.openai.com/api-keys');
294
+ }
295
+ return key;
296
+ }
297
+ headers() {
298
+ return {
299
+ 'Authorization': `Bearer ${this.apiKey}`,
300
+ 'OpenAI-Beta': 'assistants=v2',
301
+ 'Content-Type': 'application/json',
302
+ };
303
+ }
304
+ /**
305
+ * Make a GET request to the OpenAI API with retry on 429.
306
+ */
307
+ async apiGet(path) {
308
+ return this.apiRequest('GET', path);
309
+ }
310
+ /**
311
+ * Make a POST request to the OpenAI API with retry on 429.
312
+ */
313
+ async apiPost(path, body) {
314
+ return this.apiRequest('POST', path, body);
315
+ }
316
+ /**
317
+ * Core API request with retry logic for rate limits.
318
+ */
319
+ async apiRequest(method, path, body) {
320
+ let lastError = null;
321
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
322
+ const url = `${OPENAI_BASE}${path}`;
323
+ const opts = {
324
+ method,
325
+ headers: this.headers(),
326
+ };
327
+ if (body !== undefined) {
328
+ opts.body = JSON.stringify(body);
329
+ }
330
+ const res = await fetch(url, opts);
331
+ if (res.status === 429) {
332
+ const retryAfter = res.headers.get('retry-after');
333
+ const waitMs = retryAfter
334
+ ? parseInt(retryAfter, 10) * 1000
335
+ : INITIAL_BACKOFF_MS * Math.pow(2, attempt);
336
+ this.log(` Rate limited, retrying in ${Math.round(waitMs / 1000)}s...`);
337
+ await this.sleep(waitMs);
338
+ continue;
339
+ }
340
+ if (!res.ok) {
341
+ const errorBody = await res.text();
342
+ lastError = new Error(`OpenAI API error ${res.status} ${method} ${path}: ${errorBody}`);
343
+ if (res.status >= 500) {
344
+ // Retry on server errors
345
+ await this.sleep(INITIAL_BACKOFF_MS * Math.pow(2, attempt));
346
+ continue;
347
+ }
348
+ throw lastError;
349
+ }
350
+ return (await res.json());
351
+ }
352
+ throw lastError ?? new Error(`Failed after ${MAX_RETRIES} retries: ${method} ${path}`);
353
+ }
354
+ /**
355
+ * GET raw bytes from the API (for file content download).
356
+ */
357
+ async apiGetBytes(path) {
358
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
359
+ const url = `${OPENAI_BASE}${path}`;
360
+ const res = await fetch(url, {
361
+ method: 'GET',
362
+ headers: {
363
+ 'Authorization': `Bearer ${this.apiKey}`,
364
+ 'OpenAI-Beta': 'assistants=v2',
365
+ },
366
+ });
367
+ if (res.status === 429) {
368
+ const retryAfter = res.headers.get('retry-after');
369
+ const waitMs = retryAfter
370
+ ? parseInt(retryAfter, 10) * 1000
371
+ : INITIAL_BACKOFF_MS * Math.pow(2, attempt);
372
+ await this.sleep(waitMs);
373
+ continue;
374
+ }
375
+ if (!res.ok) {
376
+ const errorBody = await res.text();
377
+ throw new Error(`OpenAI API error ${res.status} GET ${path}: ${errorBody}`);
378
+ }
379
+ const arrayBuf = await res.arrayBuffer();
380
+ return Buffer.from(arrayBuf);
381
+ }
382
+ throw new Error(`Failed to download after ${MAX_RETRIES} retries: ${path}`);
383
+ }
384
+ /**
385
+ * Upload a file using multipart/form-data.
386
+ */
387
+ async uploadFile(filename, content) {
388
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
389
+ const formData = new FormData();
390
+ const blob = new Blob([content]);
391
+ formData.append('file', blob, filename);
392
+ formData.append('purpose', 'assistants');
393
+ const res = await fetch(`${OPENAI_BASE}/files`, {
394
+ method: 'POST',
395
+ headers: {
396
+ 'Authorization': `Bearer ${this.apiKey}`,
397
+ // Don't set Content-Type for FormData — fetch sets it with boundary
398
+ },
399
+ body: formData,
400
+ });
401
+ if (res.status === 429) {
402
+ const retryAfter = res.headers.get('retry-after');
403
+ const waitMs = retryAfter
404
+ ? parseInt(retryAfter, 10) * 1000
405
+ : INITIAL_BACKOFF_MS * Math.pow(2, attempt);
406
+ await this.sleep(waitMs);
407
+ continue;
408
+ }
409
+ if (!res.ok) {
410
+ const errorBody = await res.text();
411
+ throw new Error(`File upload failed (${res.status}): ${errorBody}`);
412
+ }
413
+ const fileObj = (await res.json());
414
+ return fileObj.id;
415
+ }
416
+ throw new Error(`File upload failed after ${MAX_RETRIES} retries`);
417
+ }
418
+ /**
419
+ * Paginate through a list endpoint, collecting all items.
420
+ */
421
+ async paginateList(path, queryParams) {
422
+ const items = [];
423
+ let after;
424
+ while (true) {
425
+ const params = new URLSearchParams();
426
+ params.set('limit', '100');
427
+ if (after)
428
+ params.set('after', after);
429
+ const fullPath = queryParams
430
+ ? `${path}?${params.toString()}&${queryParams}`
431
+ : `${path}?${params.toString()}`;
432
+ const response = await this.apiGet(fullPath);
433
+ items.push(...response.data);
434
+ if (!response.has_more || response.data.length === 0)
435
+ break;
436
+ after = response.last_id;
437
+ }
438
+ return items;
439
+ }
440
+ // ─── Private: Extract helpers ──────────────────────────────
441
+ /**
442
+ * Resolve which assistant to extract. Uses SAVESTATE_OPENAI_ASSISTANT_ID
443
+ * if set, otherwise lists all and picks the first (or errors if none).
444
+ */
445
+ async resolveAssistant() {
446
+ const targetId = process.env.SAVESTATE_OPENAI_ASSISTANT_ID;
447
+ if (targetId) {
448
+ this.log(`Fetching assistant: ${targetId}`);
449
+ return this.apiGet(`/assistants/${targetId}`);
450
+ }
451
+ // List all assistants
452
+ this.log('No SAVESTATE_OPENAI_ASSISTANT_ID set, listing assistants...');
453
+ const assistants = await this.paginateList('/assistants');
454
+ if (assistants.length === 0) {
455
+ throw new Error('No assistants found in your OpenAI account.\n' +
456
+ 'Create one at: https://platform.openai.com/assistants');
457
+ }
458
+ if (assistants.length === 1) {
459
+ this.log(`Found 1 assistant, using: ${assistants[0].name ?? assistants[0].id}`);
460
+ return assistants[0];
461
+ }
462
+ // Multiple assistants — list them and ask user to specify
463
+ console.error('');
464
+ console.error('Multiple assistants found:');
465
+ for (const a of assistants) {
466
+ console.error(` ${a.id} ${a.name ?? '(unnamed)'} [${a.model}]`);
467
+ }
468
+ console.error('');
469
+ console.error('Set SAVESTATE_OPENAI_ASSISTANT_ID to target a specific assistant.');
470
+ console.error(`Using first assistant: ${assistants[0].name ?? assistants[0].id}`);
471
+ console.error('');
472
+ return assistants[0];
473
+ }
474
+ /**
475
+ * Extract identity info from the assistant object.
476
+ */
477
+ extractIdentity(assistant) {
478
+ const personality = assistant.instructions ?? '';
479
+ const config = {
480
+ assistant_id: assistant.id,
481
+ name: assistant.name,
482
+ description: assistant.description,
483
+ model: assistant.model,
484
+ metadata: assistant.metadata,
485
+ temperature: assistant.temperature,
486
+ top_p: assistant.top_p,
487
+ response_format: assistant.response_format,
488
+ created_at: assistant.created_at,
489
+ // Preserve the raw tools array for restore
490
+ tools_raw: assistant.tools,
491
+ // Preserve tool_resources for restore
492
+ tool_resources: assistant.tool_resources,
493
+ };
494
+ // Convert tools to SaveState ToolConfig format
495
+ const tools = assistant.tools.map(tool => {
496
+ if (tool.type === 'function' && tool.function) {
497
+ return {
498
+ name: tool.function.name,
499
+ type: 'function',
500
+ config: {
501
+ description: tool.function.description,
502
+ parameters: tool.function.parameters,
503
+ strict: tool.function.strict,
504
+ },
505
+ enabled: true,
506
+ };
507
+ }
508
+ return {
509
+ name: tool.type,
510
+ type: tool.type,
511
+ config: {},
512
+ enabled: true,
513
+ };
514
+ });
515
+ return { personality, config, tools };
516
+ }
517
+ /**
518
+ * Extract files and vector stores associated with the assistant.
519
+ */
520
+ async extractFiles(assistant) {
521
+ const knowledge = [];
522
+ const fileContents = {};
523
+ const vectorStores = [];
524
+ // Collect all file IDs we need to download
525
+ const allFileIds = new Set();
526
+ // Code interpreter files
527
+ const ciFileIds = assistant.tool_resources?.code_interpreter?.file_ids ?? [];
528
+ for (const fid of ciFileIds) {
529
+ allFileIds.add(fid);
530
+ }
531
+ // Vector store files
532
+ const vsIds = assistant.tool_resources?.file_search?.vector_store_ids ?? [];
533
+ if (vsIds.length > 0) {
534
+ this.log(` Fetching ${vsIds.length} vector store(s)...`);
535
+ }
536
+ for (const vsId of vsIds) {
537
+ try {
538
+ const vs = await this.apiGet(`/vector_stores/${vsId}`);
539
+ const vsFiles = await this.paginateList(`/vector_stores/${vsId}/files`);
540
+ const vsFileIds = vsFiles.map(f => f.id);
541
+ for (const fid of vsFileIds) {
542
+ allFileIds.add(fid);
543
+ }
544
+ vectorStores.push({
545
+ id: vs.id,
546
+ name: vs.name,
547
+ metadata: vs.metadata,
548
+ file_ids: vsFileIds,
549
+ expires_after: vs.expires_after,
550
+ });
551
+ this.log(` Vector store "${vs.name}": ${vsFileIds.length} files`);
552
+ }
553
+ catch (err) {
554
+ this.warn(`Failed to fetch vector store ${vsId}: ${err instanceof Error ? err.message : String(err)}`);
555
+ }
556
+ }
557
+ // Download all files
558
+ if (allFileIds.size > 0) {
559
+ this.log(` Downloading ${allFileIds.size} file(s)...`);
560
+ }
561
+ for (const fileId of allFileIds) {
562
+ try {
563
+ // Get file metadata
564
+ const fileMeta = await this.apiGet(`/files/${fileId}`);
565
+ // Download file content
566
+ const content = await this.apiGetBytes(`/files/${fileId}/content`);
567
+ const checksum = computeChecksum(content);
568
+ knowledge.push({
569
+ id: `openai-file:${fileId}`,
570
+ filename: fileMeta.filename,
571
+ mimeType: this.guessMimeType(fileMeta.filename),
572
+ path: `knowledge/files/${fileMeta.filename}`,
573
+ size: fileMeta.bytes,
574
+ checksum,
575
+ });
576
+ // Store file content as base64 for restore
577
+ fileContents[fileId] = content.toString('base64');
578
+ this.log(` Downloaded: ${fileMeta.filename} (${this.formatBytes(fileMeta.bytes)})`);
579
+ }
580
+ catch (err) {
581
+ this.warn(`Failed to download file ${fileId}: ${err instanceof Error ? err.message : String(err)}`);
582
+ }
583
+ }
584
+ return { knowledge, fileContents, vectorStores };
585
+ }
586
+ /**
587
+ * Extract threads using the local thread cache.
588
+ * OpenAI has no "list all threads" endpoint, so we rely on cached IDs.
589
+ */
590
+ async extractThreads(assistantId) {
591
+ const conversations = [];
592
+ const conversationDetails = [];
593
+ // Try to load cached thread IDs
594
+ const threadIds = await this.loadThreadCache(assistantId);
595
+ if (threadIds.length === 0) {
596
+ console.error(' ℹ No cached thread IDs found.');
597
+ console.error(' OpenAI does not provide a "list all threads" endpoint.');
598
+ console.error(` To capture threads, create ${THREADS_CACHE_DIR}/${THREADS_CACHE_FILE}`);
599
+ console.error(' with format: { "assistant_id": "...", "thread_ids": ["thread_..."] }');
600
+ console.error(' Thread IDs are returned when you create threads via the API.');
601
+ return { conversations, conversationDetails };
602
+ }
603
+ this.log(` Found ${threadIds.length} cached thread(s), fetching messages...`);
604
+ for (const threadId of threadIds) {
605
+ try {
606
+ const messages = await this.paginateList(`/threads/${threadId}/messages`, 'order=asc');
607
+ if (messages.length === 0)
608
+ continue;
609
+ const firstMsg = messages[0];
610
+ const lastMsg = messages[messages.length - 1];
611
+ const convertedMessages = messages.map(msg => ({
612
+ id: msg.id,
613
+ role: msg.role,
614
+ content: this.extractMessageContent(msg),
615
+ timestamp: new Date(msg.created_at * 1000).toISOString(),
616
+ metadata: {
617
+ ...msg.metadata,
618
+ ...(msg.run_id ? { run_id: msg.run_id } : {}),
619
+ },
620
+ }));
621
+ const conv = {
622
+ id: threadId,
623
+ title: `Thread ${threadId.slice(0, 12)}...`,
624
+ createdAt: new Date(firstMsg.created_at * 1000).toISOString(),
625
+ updatedAt: new Date(lastMsg.created_at * 1000).toISOString(),
626
+ messages: convertedMessages,
627
+ metadata: { thread_id: threadId },
628
+ };
629
+ conversationDetails.push(conv);
630
+ conversations.push({
631
+ id: threadId,
632
+ title: conv.title,
633
+ createdAt: conv.createdAt,
634
+ updatedAt: conv.updatedAt,
635
+ messageCount: messages.length,
636
+ path: `conversations/${threadId}.json`,
637
+ });
638
+ this.log(` Thread ${threadId.slice(0, 16)}...: ${messages.length} messages`);
639
+ }
640
+ catch (err) {
641
+ this.warn(`Failed to fetch thread ${threadId}: ${err instanceof Error ? err.message : String(err)}`);
642
+ }
643
+ }
644
+ // Save thread details as memory entries so restore can access them
645
+ // We store the full messages JSON as the content of a memory entry
646
+ for (const conv of conversationDetails) {
647
+ // This is a bit of a hack — we store conversation data in memory.core
648
+ // because that's the easiest way to persist it through the snapshot format
649
+ }
650
+ return { conversations, conversationDetails };
651
+ }
652
+ /**
653
+ * Extract text content from an OpenAI message.
654
+ */
655
+ extractMessageContent(msg) {
656
+ const parts = [];
657
+ for (const content of msg.content) {
658
+ if (content.type === 'text' && content.text) {
659
+ parts.push(content.text.value);
660
+ }
661
+ else if (content.type === 'image_file' && content.image_file) {
662
+ parts.push(`[Image: ${content.image_file.file_id}]`);
663
+ }
664
+ else if (content.type === 'image_url' && content.image_url) {
665
+ parts.push(`[Image: ${content.image_url.url}]`);
666
+ }
667
+ }
668
+ return parts.join('\n');
669
+ }
670
+ // ─── Private: Restore helpers ──────────────────────────────
671
+ /**
672
+ * Create a vector store and add files to it.
673
+ */
674
+ async createVectorStore(name, fileIds, metadata, expiresAfter) {
675
+ const body = {
676
+ name,
677
+ metadata,
678
+ file_ids: fileIds,
679
+ };
680
+ if (expiresAfter) {
681
+ body.expires_after = expiresAfter;
682
+ }
683
+ const vs = await this.apiPost('/vector_stores', body);
684
+ return vs.id;
685
+ }
686
+ /**
687
+ * Recreate a thread with messages.
688
+ */
689
+ async recreateThread(messages) {
690
+ // Create thread with initial messages
691
+ // The API supports creating a thread with messages in one call
692
+ const threadMessages = messages
693
+ .filter(m => m.role === 'user' || m.role === 'assistant')
694
+ .map(m => ({
695
+ role: m.role,
696
+ content: m.content,
697
+ }));
698
+ const thread = await this.apiPost('/threads', { messages: threadMessages });
699
+ return thread.id;
700
+ }
701
+ // ─── Private: Thread cache ─────────────────────────────────
702
+ /**
703
+ * Load thread IDs from local cache.
704
+ */
705
+ async loadThreadCache(assistantId) {
706
+ const cachePath = join(process.cwd(), THREADS_CACHE_DIR, THREADS_CACHE_FILE);
707
+ if (!existsSync(cachePath))
708
+ return [];
709
+ try {
710
+ const raw = await readFile(cachePath, 'utf-8');
711
+ const cache = JSON.parse(raw);
712
+ // Support both single-assistant and multi-assistant cache formats
713
+ if (Array.isArray(cache)) {
714
+ const entry = cache.find(c => c.assistant_id === assistantId);
715
+ return entry?.thread_ids ?? [];
716
+ }
717
+ // Single entry — use it if it matches the assistant or if no assistant ID is set
718
+ if (cache.assistant_id === assistantId || !cache.assistant_id) {
719
+ return cache.thread_ids ?? [];
720
+ }
721
+ return [];
722
+ }
723
+ catch (err) {
724
+ this.warn(`Failed to read thread cache: ${err instanceof Error ? err.message : String(err)}`);
725
+ return [];
726
+ }
727
+ }
728
+ /**
729
+ * Save thread IDs to local cache.
730
+ */
731
+ async saveThreadCache(assistantId, threadIds) {
732
+ const cacheDir = join(process.cwd(), THREADS_CACHE_DIR);
733
+ const cachePath = join(cacheDir, THREADS_CACHE_FILE);
734
+ try {
735
+ await mkdir(cacheDir, { recursive: true });
736
+ let caches = [];
737
+ // Load existing cache
738
+ if (existsSync(cachePath)) {
739
+ try {
740
+ const raw = await readFile(cachePath, 'utf-8');
741
+ const parsed = JSON.parse(raw);
742
+ if (Array.isArray(parsed)) {
743
+ caches = parsed;
744
+ }
745
+ else if (parsed.assistant_id) {
746
+ caches = [parsed];
747
+ }
748
+ }
749
+ catch {
750
+ // Start fresh
751
+ }
752
+ }
753
+ // Upsert entry for this assistant
754
+ const existingIdx = caches.findIndex(c => c.assistant_id === assistantId);
755
+ const entry = {
756
+ assistant_id: assistantId,
757
+ thread_ids: [...new Set([
758
+ ...(existingIdx >= 0 ? caches[existingIdx].thread_ids : []),
759
+ ...threadIds,
760
+ ])],
761
+ updated_at: new Date().toISOString(),
762
+ };
763
+ if (existingIdx >= 0) {
764
+ caches[existingIdx] = entry;
765
+ }
766
+ else {
767
+ caches.push(entry);
768
+ }
769
+ await writeFile(cachePath, JSON.stringify(caches, null, 2) + '\n', 'utf-8');
770
+ this.log(` Saved ${threadIds.length} thread ID(s) to ${THREADS_CACHE_DIR}/${THREADS_CACHE_FILE}`);
771
+ }
772
+ catch (err) {
773
+ this.warn(`Failed to save thread cache: ${err instanceof Error ? err.message : String(err)}`);
774
+ }
775
+ }
776
+ // ─── Private: Utilities ────────────────────────────────────
777
+ log(msg) {
778
+ console.error(msg);
779
+ }
780
+ warn(msg) {
781
+ this.warnings.push(msg);
782
+ }
783
+ sleep(ms) {
784
+ return new Promise(resolve => setTimeout(resolve, ms));
785
+ }
786
+ formatBytes(bytes) {
787
+ if (bytes < 1024)
788
+ return `${bytes}B`;
789
+ if (bytes < 1024 * 1024)
790
+ return `${(bytes / 1024).toFixed(1)}KB`;
791
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
792
+ }
793
+ guessMimeType(filename) {
794
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
795
+ const map = {
796
+ pdf: 'application/pdf',
797
+ txt: 'text/plain',
798
+ md: 'text/markdown',
799
+ json: 'application/json',
800
+ csv: 'text/csv',
801
+ tsv: 'text/tab-separated-values',
802
+ html: 'text/html',
803
+ htm: 'text/html',
804
+ xml: 'application/xml',
805
+ js: 'application/javascript',
806
+ ts: 'text/typescript',
807
+ py: 'text/x-python',
808
+ rb: 'text/x-ruby',
809
+ c: 'text/x-c',
810
+ cpp: 'text/x-c++src',
811
+ h: 'text/x-c',
812
+ java: 'text/x-java',
813
+ rs: 'text/x-rust',
814
+ go: 'text/x-go',
815
+ sh: 'application/x-sh',
816
+ yaml: 'application/x-yaml',
817
+ yml: 'application/x-yaml',
818
+ toml: 'application/toml',
819
+ png: 'image/png',
820
+ jpg: 'image/jpeg',
821
+ jpeg: 'image/jpeg',
822
+ gif: 'image/gif',
823
+ webp: 'image/webp',
824
+ svg: 'image/svg+xml',
825
+ mp3: 'audio/mpeg',
826
+ wav: 'audio/wav',
827
+ mp4: 'video/mp4',
828
+ zip: 'application/zip',
829
+ tar: 'application/x-tar',
830
+ gz: 'application/gzip',
831
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
832
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
833
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
834
+ };
835
+ return map[ext] ?? 'application/octet-stream';
836
+ }
113
837
  }
114
838
  //# sourceMappingURL=openai-assistants.js.map