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/README.md +239 -0
- package/bin/cli.js +655 -0
- package/bin/regenerate.js +95 -0
- package/package.json +43 -0
- package/portal/exodus_mcp.py +300 -0
- package/portal/schema.sql +158 -0
- package/portal/worker.js +2410 -0
- package/prompts/index.js +317 -0
- package/src/analyzer.js +676 -0
- package/src/checkpoint.js +109 -0
- package/src/claude.js +147 -0
- package/src/config.js +40 -0
- package/src/deploy.js +193 -0
- package/src/generator.js +822 -0
- package/src/import.js +185 -0
- package/src/parser.js +445 -0
- package/src/spinner.js +55 -0
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();
|