ai-exodus 2.0.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/bin/cli.js ADDED
@@ -0,0 +1,655 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from 'node:util';
4
+ import { resolve, basename } from 'node:path';
5
+ import { existsSync, statSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { parse } from '../src/parser.js';
8
+ import { analyze } from '../src/analyzer.js';
9
+ import { generate } from '../src/generator.js';
10
+ import { checkCLI } from '../src/claude.js';
11
+ import { deploy } from '../src/deploy.js';
12
+ import { importExport } from '../src/import.js';
13
+ import { loadConfig } from '../src/config.js';
14
+
15
+ const VERSION = '2.0.0';
16
+
17
+ const HELP = `
18
+ ai-exodus v${VERSION}
19
+ Migrate your AI relationship from any platform to Claude.
20
+
21
+ Usage:
22
+ ai-exodus deploy Deploy your personal portal
23
+ ai-exodus import <export-file> [options] Import chat history to portal
24
+ ai-exodus analyze [options] Analyze imported data (from portal)
25
+ ai-exodus migrate <export-file> [options] Classic: parse + analyze + generate locally
26
+ ai-exodus formats Show supported export formats
27
+ ai-exodus config Show current configuration
28
+ ai-exodus --help Show this help
29
+
30
+ Deploy options:
31
+ --verbose, -v Show detailed output
32
+
33
+ Import options:
34
+ --format, -f <format> Source format: chatgpt, raw (default: auto-detect)
35
+ --from <date> Only conversations from this date (YYYY-MM-DD)
36
+ --to <date> Only conversations up to this date (YYYY-MM-DD)
37
+ --min-messages <n> Skip conversations shorter than n messages (default: 10)
38
+ --only-models <m,...> Only include convos using these GPT models
39
+ --portal-url <url> Portal URL (default: from config)
40
+ --password <pw> Portal password
41
+ --verbose, -v Show detailed progress
42
+
43
+ Analyze options:
44
+ --passes <list> Which passes to run: index,persona,memory,skills,relationship,all (default: all)
45
+ --model <model> Claude model (default: sonnet)
46
+ --fast Use Haiku for indexing & skills passes
47
+ --from <date> Only analyze conversations from this date
48
+ --to <date> Only analyze conversations up to this date
49
+ --only-models <m,...> Only analyze convos using these models
50
+ --name <name> Your AI's name
51
+ --user <name> Your name
52
+ --nsfw Include NSFW content
53
+ --portal-url <url> Portal URL (default: from config)
54
+ --password <pw> Portal password
55
+ --output, -o <dir> Also write local files (default: portal only)
56
+ --verbose, -v Show detailed progress
57
+
58
+ Migrate options (classic local mode):
59
+ --output, -o <dir> Output directory (default: ./exodus-output)
60
+ --format, -f <format> Source format: chatgpt, raw (default: auto-detect)
61
+ --hearthline Include Hearthline-ready package
62
+ --letta Include Letta (MemGPT) memory import package
63
+ --nsfw Include NSFW/intimate content in output
64
+ --name <name> Your AI's name
65
+ --user <name> Your name
66
+ --from <date> Only conversations from this date (YYYY-MM-DD)
67
+ --to <date> Only conversations up to this date (YYYY-MM-DD)
68
+ --min-messages <n> Skip conversations shorter than n messages (default: 10)
69
+ --only-models <m,...> Only include convos using these GPT models
70
+ --fast Use Haiku for indexing & skills (saves ~30% tokens)
71
+ --model <model> Claude model to use (default: sonnet)
72
+ --verbose, -v Show detailed progress
73
+ --help, -h Show this help
74
+ --version Show version
75
+
76
+ Requires:
77
+ Claude Code CLI installed and logged in (runs on your subscription, no API key needed)
78
+ Install: npm install -g @anthropic-ai/claude-code
79
+
80
+ Examples:
81
+ ai-exodus deploy
82
+ ai-exodus import conversations.json
83
+ ai-exodus analyze --passes persona,memory --model sonnet --from 2024-06
84
+ ai-exodus migrate export.json --name "Cass" --user "Marta" --hearthline
85
+ ai-exodus migrate export.json --from 2025-06-01 --to 2025-12-31
86
+ `;
87
+
88
+ const FORMATS = `
89
+ Supported Export Formats:
90
+
91
+ chatgpt ChatGPT JSON export (Settings > Data Controls > Export Data)
92
+ File: conversations.json inside the ZIP
93
+ Richest data — full history, timestamps, model info
94
+
95
+ raw Plain text conversation logs (TXT, MD)
96
+ Copy-pasted transcripts, any platform
97
+ Less metadata but still extracts personality + memory
98
+
99
+ Coming soon:
100
+ cai Character.AI conversation exports
101
+ replika Replika GDPR data export
102
+ tavern SillyTavern JSONL / character cards
103
+ `;
104
+
105
+ async function main() {
106
+ const args = process.argv.slice(2);
107
+
108
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
109
+ console.log(HELP);
110
+ process.exit(0);
111
+ }
112
+
113
+ if (args.includes('--version')) {
114
+ console.log(VERSION);
115
+ process.exit(0);
116
+ }
117
+
118
+ const command = args[0];
119
+
120
+ if (command === 'formats') {
121
+ console.log(FORMATS);
122
+ process.exit(0);
123
+ }
124
+
125
+ // ── Deploy ──
126
+ if (command === 'deploy') {
127
+ const { values: deployVals } = parseArgs({
128
+ args: args.slice(1),
129
+ options: { verbose: { type: 'boolean', short: 'v', default: false } },
130
+ allowPositionals: true,
131
+ });
132
+ await deploy({ verbose: deployVals.verbose });
133
+ process.exit(0);
134
+ }
135
+
136
+ // ── Import ──
137
+ if (command === 'import') {
138
+ const { values: importVals, positionals: importPos } = parseArgs({
139
+ args: args.slice(1),
140
+ options: {
141
+ format: { type: 'string', short: 'f' },
142
+ from: { type: 'string' },
143
+ to: { type: 'string' },
144
+ 'min-messages': { type: 'string', default: '10' },
145
+ 'only-models': { type: 'string' },
146
+ 'portal-url': { type: 'string' },
147
+ password: { type: 'string' },
148
+ verbose: { type: 'boolean', short: 'v', default: false },
149
+ },
150
+ allowPositionals: true,
151
+ });
152
+ const inputFile = importPos[0];
153
+ if (!inputFile) {
154
+ console.error('Error: No input file specified.\nUsage: ai-exodus import <export-file>');
155
+ process.exit(1);
156
+ }
157
+ await importExport(inputFile, {
158
+ format: importVals.format,
159
+ verbose: importVals.verbose,
160
+ from: importVals.from,
161
+ to: importVals.to,
162
+ minMessages: importVals['min-messages'],
163
+ modelFilter: importVals['only-models'] ? importVals['only-models'].split(',').map(s => s.trim()) : null,
164
+ portalUrl: importVals['portal-url'],
165
+ password: importVals.password,
166
+ });
167
+ process.exit(0);
168
+ }
169
+
170
+ // ── Analyze (portal mode) ──
171
+ if (command === 'analyze') {
172
+ const { values: analyzeVals } = parseArgs({
173
+ args: args.slice(1),
174
+ options: {
175
+ passes: { type: 'string', default: 'all' },
176
+ model: { type: 'string', default: 'sonnet' },
177
+ fast: { type: 'boolean', default: false },
178
+ from: { type: 'string' },
179
+ to: { type: 'string' },
180
+ 'only-models': { type: 'string' },
181
+ name: { type: 'string' },
182
+ user: { type: 'string' },
183
+ nsfw: { type: 'boolean', default: false },
184
+ 'portal-url': { type: 'string' },
185
+ password: { type: 'string' },
186
+ output: { type: 'string', short: 'o' },
187
+ verbose: { type: 'boolean', short: 'v', default: false },
188
+ },
189
+ allowPositionals: true,
190
+ });
191
+
192
+ // Determine which passes to run
193
+ const passMap = { index: 1, persona: 2, personality: 2, memory: 3, skills: 4, relationship: 5 };
194
+ let selectedPasses;
195
+ if (analyzeVals.passes === 'all') {
196
+ selectedPasses = [1, 2, 3, 4, 5];
197
+ } else {
198
+ selectedPasses = [...new Set(
199
+ analyzeVals.passes.split(',').map(p => passMap[p.trim().toLowerCase()]).filter(Boolean)
200
+ )].sort();
201
+ // Index (pass 1) is always required as dependency
202
+ if (!selectedPasses.includes(1)) selectedPasses.unshift(1);
203
+ }
204
+
205
+ const config = await loadConfig();
206
+ const portalUrl = analyzeVals['portal-url'] || config.portalUrl;
207
+
208
+ // Check Claude CLI
209
+ const cli = await checkCLI();
210
+ if (!cli.ok) {
211
+ console.error('Error: Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code');
212
+ process.exit(1);
213
+ }
214
+
215
+ console.log('');
216
+ console.log(' ╔══════════════════════════════════════╗');
217
+ console.log(' ║ AI EXODUS — Analyze Data ║');
218
+ console.log(' ╚══════════════════════════════════════╝');
219
+ console.log('');
220
+ console.log(' Passes: ' + selectedPasses.map(p => ['Index','Personality','Memory','Skills','Relationship'][p-1]).join(', '));
221
+ console.log(' Model: ' + analyzeVals.model);
222
+ if (analyzeVals.fast) console.log(' Fast: yes (Haiku for indexing & skills)');
223
+ if (portalUrl) console.log(' Portal: ' + portalUrl);
224
+ if (analyzeVals.from || analyzeVals.to) console.log(' Dates: ' + (analyzeVals.from || 'start') + ' -> ' + (analyzeVals.to || 'end'));
225
+ console.log('');
226
+
227
+ // If portal is configured, fetch conversations from it
228
+ let parsed;
229
+ if (portalUrl) {
230
+ console.log(' Fetching conversations from portal...');
231
+ let cookie = '';
232
+ const password = analyzeVals.password || config.portalPassword;
233
+ if (password) {
234
+ const loginRes = await fetch(portalUrl + '/login', {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json' },
237
+ body: JSON.stringify({ password }),
238
+ });
239
+ if (loginRes.ok) {
240
+ const setCookie = loginRes.headers.get('set-cookie') || '';
241
+ cookie = setCookie.split(';')[0];
242
+ }
243
+ }
244
+
245
+ // Fetch all conversations
246
+ let allConvos = [];
247
+ let page = 1;
248
+ let hasMore = true;
249
+ while (hasMore) {
250
+ let qs = `conversations?page=${page}&limit=100`;
251
+ if (analyzeVals.from) qs += '&from=' + analyzeVals.from;
252
+ if (analyzeVals.to) qs += '&to=' + analyzeVals.to;
253
+ // Note: portal API only filters by single model — fetch broadly, filter locally
254
+ // if (analyzeVals['only-models']) qs += '&model=' + ...;
255
+
256
+ const res = await fetch(portalUrl + '/api/' + qs, {
257
+ headers: cookie ? { Cookie: cookie } : {},
258
+ });
259
+ if (!res.ok) {
260
+ const errText = await res.text().catch(() => '');
261
+ console.error(' Error fetching conversations: HTTP ' + res.status);
262
+ if (res.status === 401) console.error(' Check your password (--password flag or ~/.exodus/config.json)');
263
+ console.error(' ' + errText.slice(0, 200));
264
+ process.exit(1);
265
+ }
266
+ const data = await res.json();
267
+ if (!data.conversations?.length) { hasMore = false; break; }
268
+ allConvos.push(...data.conversations);
269
+ hasMore = page < data.pages;
270
+ page++;
271
+ }
272
+
273
+ console.log(' Found ' + allConvos.length + ' conversations');
274
+
275
+ // Fetch messages for each conversation
276
+ console.log(' Fetching message content...');
277
+ const conversations = [];
278
+ for (let i = 0; i < allConvos.length; i++) {
279
+ const convo = allConvos[i];
280
+ process.stdout.write(`\r ${i + 1}/${allConvos.length}...`);
281
+ // Paginate through all messages
282
+ let allMessages = [];
283
+ let msgPage = 1;
284
+ let msgHasMore = true;
285
+ while (msgHasMore) {
286
+ const msgRes = await fetch(portalUrl + '/api/conversations/' + convo.id + '/messages?limit=500&page=' + msgPage, {
287
+ headers: cookie ? { Cookie: cookie } : {},
288
+ });
289
+ const msgData = await msgRes.json();
290
+ if (!msgData.messages?.length) { msgHasMore = false; break; }
291
+ allMessages.push(...msgData.messages);
292
+ msgHasMore = allMessages.length < msgData.total;
293
+ msgPage++;
294
+ }
295
+ const msgData = { messages: allMessages };
296
+ conversations.push({
297
+ id: convo.id,
298
+ title: convo.title,
299
+ createdAt: convo.created_at ? new Date(convo.created_at) : null,
300
+ updatedAt: convo.updated_at ? new Date(convo.updated_at) : null,
301
+ model: convo.model,
302
+ messageCount: msgData.messages?.length || 0,
303
+ messages: (msgData.messages || []).map(m => ({
304
+ role: m.role,
305
+ content: m.content,
306
+ model: m.model,
307
+ timestamp: m.created_at ? new Date(m.created_at) : null,
308
+ })),
309
+ });
310
+ }
311
+ console.log('');
312
+
313
+ // Local model filter (portal API only supports single model filter)
314
+ let filtered = conversations;
315
+ if (analyzeVals['only-models']) {
316
+ const modelFilters = analyzeVals['only-models'].split(',').map(m => m.trim().toLowerCase());
317
+ filtered = conversations.filter(c => {
318
+ const convoModels = c.messages.map(m => (m.model || '').toLowerCase()).filter(Boolean);
319
+ return convoModels.some(cm => modelFilters.some(f => cm.includes(f)));
320
+ });
321
+ console.log(' Model filter: ' + filtered.length + '/' + conversations.length + ' conversations match');
322
+ }
323
+
324
+ const totalMsgs = filtered.reduce((sum, c) => sum + c.messageCount, 0);
325
+ const dates = filtered.map(c => c.createdAt).filter(Boolean).sort();
326
+ parsed = {
327
+ source: 'portal',
328
+ conversations: filtered,
329
+ messageCount: totalMsgs,
330
+ dateRange: { from: dates[0] || 'unknown', to: dates[dates.length - 1] || 'unknown' },
331
+ };
332
+ } else {
333
+ console.error(' Error: No portal URL. Run `ai-exodus deploy` first, or use --portal-url <url>');
334
+ process.exit(1);
335
+ }
336
+
337
+ console.log(' Starting analysis...');
338
+ console.log('');
339
+
340
+ const outputDir = resolve(analyzeVals.output || './exodus-output');
341
+ const analysis = await analyze(parsed, {
342
+ outputDir,
343
+ model: analyzeVals.model,
344
+ fast: analyzeVals.fast,
345
+ aiName: analyzeVals.name,
346
+ userName: analyzeVals.user,
347
+ includeNsfw: analyzeVals.nsfw,
348
+ verbose: analyzeVals.verbose,
349
+ selectedPasses,
350
+ });
351
+
352
+ // Push results to portal
353
+ if (portalUrl) {
354
+ console.log('');
355
+ console.log(' Pushing results to portal...');
356
+ let cookie = '';
357
+ const password = analyzeVals.password || config.portalPassword;
358
+ if (password) {
359
+ const loginRes = await fetch(portalUrl + '/login', {
360
+ method: 'POST',
361
+ headers: { 'Content-Type': 'application/json' },
362
+ body: JSON.stringify({ password }),
363
+ });
364
+ if (loginRes.ok) {
365
+ const setCookie = loginRes.headers.get('set-cookie') || '';
366
+ cookie = setCookie.split(';')[0];
367
+ }
368
+ }
369
+
370
+ // Push analysis results in chunks to avoid Worker timeout
371
+ const allMemories = flattenMemories(analysis.memory);
372
+ const allSkills = analysis.skills?.skills || [];
373
+ const CHUNK = 500; // memories per request
374
+
375
+ // 1. Skills + persona + narrative (small, one request)
376
+ console.log(' Pushing skills, persona, narrative...');
377
+ const metaRes = await fetch(portalUrl + '/api/import/analysis', {
378
+ method: 'POST',
379
+ headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) },
380
+ body: JSON.stringify({
381
+ skills: allSkills,
382
+ memories: [],
383
+ persona: analysis.persona || '',
384
+ narrative: analysis.relationship || '',
385
+ stats: analysis.stats,
386
+ }),
387
+ });
388
+ if (!metaRes.ok) {
389
+ const errData = await metaRes.json().catch(() => ({}));
390
+ console.error(' Warning: Failed to push skills/persona: ' + (errData.error || `HTTP ${metaRes.status}`));
391
+ } else {
392
+ console.log(' ' + allSkills.length + ' skills, persona, narrative pushed.');
393
+ }
394
+
395
+ // 2. Memories in chunks
396
+ if (allMemories.length > 0) {
397
+ console.log(' Pushing ' + allMemories.length + ' memories...');
398
+ for (let i = 0; i < allMemories.length; i += CHUNK) {
399
+ const chunk = allMemories.slice(i, i + CHUNK);
400
+ const memRes = await fetch(portalUrl + '/api/import/analysis', {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) },
403
+ body: JSON.stringify({ skills: [], memories: chunk }),
404
+ });
405
+ if (!memRes.ok) {
406
+ console.error(' Warning: Memory chunk ' + (i / CHUNK + 1) + ' failed');
407
+ } else {
408
+ process.stdout.write('\r ' + Math.min(i + CHUNK, allMemories.length) + '/' + allMemories.length + ' memories pushed');
409
+ }
410
+ }
411
+ console.log('');
412
+ }
413
+ console.log(' Results pushed to portal.');
414
+ }
415
+
416
+ // Also write local files if --output was specified
417
+ if (analyzeVals.output) {
418
+ console.log(' Writing local files to ' + outputDir + '...');
419
+ await generate(analysis, {
420
+ outputDir,
421
+ hearthline: false,
422
+ letta: false,
423
+ aiName: analyzeVals.name || analysis.personality?.name || 'AI',
424
+ userName: analyzeVals.user || analysis.memory?.userName || 'User',
425
+ });
426
+ }
427
+
428
+ console.log('');
429
+ console.log(' Analysis complete!');
430
+ if (portalUrl) console.log(' View results at: ' + portalUrl);
431
+ console.log('');
432
+ process.exit(0);
433
+ }
434
+
435
+ // ── Config ──
436
+ if (command === 'config') {
437
+ const config = await loadConfig();
438
+ console.log('');
439
+ console.log(' AI Exodus Configuration');
440
+ console.log(' ─────────────────────────');
441
+ if (config.portalUrl) console.log(' Portal URL: ' + config.portalUrl);
442
+ if (config.deployName) console.log(' Deploy name: ' + config.deployName);
443
+ if (config.dbName) console.log(' Database: ' + config.dbName);
444
+ if (config.mcpSecret) console.log(' MCP Secret: ' + config.mcpSecret);
445
+ if (!config.portalUrl) console.log(' No deployment found. Run: ai-exodus deploy');
446
+ console.log('');
447
+ process.exit(0);
448
+ }
449
+
450
+ if (command !== 'migrate') {
451
+ console.error(`Unknown command: ${command}\nRun ai-exodus --help for usage.`);
452
+ process.exit(1);
453
+ }
454
+
455
+ // Parse options
456
+ const { values, positionals } = parseArgs({
457
+ args: args.slice(1),
458
+ options: {
459
+ output: { type: 'string', short: 'o', default: './exodus-output' },
460
+ format: { type: 'string', short: 'f' },
461
+ hearthline: { type: 'boolean', default: false },
462
+ letta: { type: 'boolean', default: false },
463
+ nsfw: { type: 'boolean', default: false },
464
+ name: { type: 'string' },
465
+ user: { type: 'string' },
466
+ from: { type: 'string' },
467
+ to: { type: 'string' },
468
+ 'min-messages': { type: 'string', default: '10' },
469
+ 'only-models': { type: 'string' },
470
+ fast: { type: 'boolean', default: false },
471
+ model: { type: 'string', default: 'sonnet' },
472
+ verbose: { type: 'boolean', short: 'v', default: false },
473
+ },
474
+ allowPositionals: true,
475
+ });
476
+
477
+ const inputFile = positionals[0];
478
+ if (!inputFile) {
479
+ console.error('Error: No input file specified.\nUsage: ai-exodus migrate <export-file>');
480
+ process.exit(1);
481
+ }
482
+
483
+ // Expand ~ to home directory (Windows doesn't do this natively)
484
+ const expanded = inputFile.startsWith('~')
485
+ ? inputFile.replace(/^~/, process.env.HOME || process.env.USERPROFILE)
486
+ : inputFile;
487
+ const inputPath = resolve(expanded);
488
+ if (!existsSync(inputPath)) {
489
+ console.error(`Error: File not found: ${inputPath}`);
490
+ process.exit(1);
491
+ }
492
+
493
+ // Check Claude CLI is available
494
+ const cli = await checkCLI();
495
+ if (!cli.ok) {
496
+ console.error('Error: Claude Code CLI not found or not responding.');
497
+ console.error('Install it: npm install -g @anthropic-ai/claude-code');
498
+ console.error('Then log in: claude login');
499
+ process.exit(1);
500
+ }
501
+
502
+ const fileSize = statSync(inputPath).size;
503
+ const fileMB = (fileSize / 1024 / 1024).toFixed(1);
504
+
505
+ console.log('');
506
+ console.log(' ╔══════════════════════════════════════╗');
507
+ console.log(' ║ 🚚 AI EXODUS 🚚 ║');
508
+ console.log(' ║ Your AI belongs to you. Let\'s go. ║');
509
+ console.log(' ╚══════════════════════════════════════╝');
510
+ console.log('');
511
+ console.log(` Input: ${basename(inputPath)} (${fileMB} MB)`);
512
+ console.log(` Format: ${values.format || 'auto-detect'}`);
513
+ console.log(` Output: ${resolve(values.output)}`);
514
+ if (values.name) console.log(` AI Name: ${values.name}`);
515
+ if (values.user) console.log(` User: ${values.user}`);
516
+ console.log(` Model: ${values.model}`);
517
+ console.log(` NSFW: ${values.nsfw ? 'included' : 'excluded'}`);
518
+ if (values.from || values.to) console.log(` Dates: ${values.from || 'start'} → ${values.to || 'end'}`);
519
+ if (values['only-models']) console.log(` Models: ${values['only-models']}`);
520
+ console.log(` Min msgs: ${values['min-messages']}`);
521
+ console.log(` Hearthline: ${values.hearthline ? 'yes' : 'no'}`);
522
+ console.log(` Letta: ${values.letta ? 'yes' : 'no'}`);
523
+ if (values.fast) console.log(` Fast: yes (Haiku for indexing & skills)`);
524
+ console.log('');
525
+
526
+ // Parse date filters
527
+ const fromDate = values.from ? new Date(values.from + 'T00:00:00') : null;
528
+ const toDate = values.to ? new Date(values.to + 'T23:59:59') : null;
529
+ const minMessages = parseInt(values['min-messages'], 10) || 10;
530
+
531
+ try {
532
+ // Step 1: Parse
533
+ console.log(' ▸ Parsing export data...');
534
+ const modelFilter = values['only-models']
535
+ ? values['only-models'].split(',').map(s => s.trim())
536
+ : null;
537
+
538
+ const parsed = await parse(inputPath, {
539
+ format: values.format,
540
+ verbose: values.verbose,
541
+ minMessages,
542
+ from: fromDate,
543
+ to: toDate,
544
+ modelFilter,
545
+ });
546
+ console.log(` Found ${parsed.conversations.length} conversations, ${parsed.messageCount} messages`);
547
+ console.log(` Date range: ${parsed.dateRange.from} → ${parsed.dateRange.to}`);
548
+ console.log('');
549
+
550
+ // Step 2: Analyze (5 passes)
551
+ console.log(' ▸ Analyzing your AI relationship...');
552
+ console.log(' This takes a while. Go make a coffee — your AI is being reconstructed.');
553
+ console.log('');
554
+ const analysis = await analyze(parsed, {
555
+ outputDir: resolve(values.output),
556
+ model: values.model,
557
+ fast: values.fast,
558
+ aiName: values.name,
559
+ userName: values.user,
560
+ includeNsfw: values.nsfw,
561
+ verbose: values.verbose,
562
+ });
563
+
564
+ // Step 3: Generate output
565
+ console.log('');
566
+ console.log(' ▸ Generating migration package...');
567
+ const outputPath = await generate(analysis, {
568
+ outputDir: resolve(values.output),
569
+ hearthline: values.hearthline,
570
+ letta: values.letta,
571
+ aiName: values.name || analysis.personality.name || 'AI',
572
+ userName: values.user || analysis.memory.userName || 'User',
573
+ });
574
+
575
+ console.log('');
576
+ console.log(' ╔══════════════════════════════════════╗');
577
+ console.log(' ║ Migration complete. ║');
578
+ console.log(' ╚══════════════════════════════════════╝');
579
+ console.log('');
580
+ console.log(` Your AI has been reconstructed at:`);
581
+ console.log(` ${outputPath}`);
582
+ console.log('');
583
+ console.log(' Files:');
584
+ console.log(' custom-instructions.txt — Paste into Claude.ai (short, dense)');
585
+ console.log(' persona.md — Full personality definition');
586
+ console.log(' claude.md — Ready-to-use CLAUDE.md');
587
+ console.log(' memory/ — Everything they knew about you');
588
+ console.log(' skills/ — What they could do');
589
+ console.log(' preferences.md — How you like to communicate');
590
+ console.log(' relationship.md — Your story together');
591
+ if (values.hearthline) {
592
+ console.log(' hearthline/ — Drop into Hearthline deploy');
593
+ }
594
+ if (values.letta) {
595
+ console.log(' letta/ — Letta memory import package');
596
+ }
597
+ console.log('');
598
+ console.log(' Read relationship.md first. That\'s the one that matters.');
599
+ console.log('');
600
+
601
+ } catch (err) {
602
+ console.error(`\n Error: ${err.message}`);
603
+ if (values.verbose && err.stack) console.error(err.stack);
604
+ process.exit(1);
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Flatten nested memory object into array of {category, key, value} for portal import
610
+ */
611
+ function flattenMemories(memory) {
612
+ if (!memory) return [];
613
+ const entries = [];
614
+
615
+ function extract(obj, category) {
616
+ if (!obj) return;
617
+ for (const [key, val] of Object.entries(obj)) {
618
+ if (!val || val === 'if known' || val === 'if mentioned') continue;
619
+ if (Array.isArray(val)) {
620
+ for (const item of val) {
621
+ if (item && typeof item === 'string') {
622
+ entries.push({ category, key, value: item });
623
+ } else if (item && typeof item === 'object') {
624
+ // Timeline events etc
625
+ entries.push({ category, key, value: JSON.stringify(item) });
626
+ }
627
+ }
628
+ } else if (typeof val === 'string' && val.length > 0) {
629
+ entries.push({ category, key, value: val });
630
+ } else if (typeof val === 'object') {
631
+ extract(val, category);
632
+ }
633
+ }
634
+ }
635
+
636
+ if (memory.identity) extract(memory.identity, 'identity');
637
+ if (memory.life) extract(memory.life, 'life');
638
+ if (memory.preferences) extract(memory.preferences, 'preferences');
639
+ if (memory.personality) extract(memory.personality, 'personality');
640
+ if (memory.relationship) extract(memory.relationship, 'relationship');
641
+ if (memory.timeline) {
642
+ for (const evt of memory.timeline) {
643
+ entries.push({ category: 'timeline', key: evt.date || '', value: evt.event || JSON.stringify(evt) });
644
+ }
645
+ }
646
+ if (memory.rawFacts) {
647
+ for (const fact of memory.rawFacts) {
648
+ entries.push({ category: 'facts', key: null, value: fact });
649
+ }
650
+ }
651
+
652
+ return entries;
653
+ }
654
+
655
+ main();