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/src/generator.js
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output package generator
|
|
3
|
+
* Takes analysis results and writes the migration package to disk
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate the complete migration package
|
|
11
|
+
*/
|
|
12
|
+
export async function generate(analysis, options) {
|
|
13
|
+
const { outputDir, hearthline, letta, aiName, userName } = options;
|
|
14
|
+
|
|
15
|
+
// Create output directories
|
|
16
|
+
await mkdir(outputDir, { recursive: true });
|
|
17
|
+
await mkdir(join(outputDir, 'memory'), { recursive: true });
|
|
18
|
+
await mkdir(join(outputDir, 'skills'), { recursive: true });
|
|
19
|
+
if (hearthline) {
|
|
20
|
+
await mkdir(join(outputDir, 'hearthline'), { recursive: true });
|
|
21
|
+
await mkdir(join(outputDir, 'hearthline', 'memory'), { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Write all files in parallel
|
|
25
|
+
const writes = [];
|
|
26
|
+
|
|
27
|
+
// 1. Persona definition
|
|
28
|
+
writes.push(writeFile(
|
|
29
|
+
join(outputDir, 'persona.md'),
|
|
30
|
+
analysis.persona,
|
|
31
|
+
'utf-8'
|
|
32
|
+
));
|
|
33
|
+
|
|
34
|
+
// 2. Custom instructions (short, for Claude.ai)
|
|
35
|
+
writes.push(writeFile(
|
|
36
|
+
join(outputDir, 'custom-instructions.txt'),
|
|
37
|
+
generateCustomInstructions(analysis.customInstructions, aiName, userName),
|
|
38
|
+
'utf-8'
|
|
39
|
+
));
|
|
40
|
+
|
|
41
|
+
// 3. CLAUDE.md (ready-to-use system prompt)
|
|
42
|
+
writes.push(writeFile(
|
|
43
|
+
join(outputDir, 'claude.md'),
|
|
44
|
+
generateClaudeMd(analysis, aiName, userName),
|
|
45
|
+
'utf-8'
|
|
46
|
+
));
|
|
47
|
+
|
|
48
|
+
// 3. Memory files
|
|
49
|
+
writes.push(writeFile(
|
|
50
|
+
join(outputDir, 'memory', 'about-user.md'),
|
|
51
|
+
generateUserMemory(analysis.memory, userName),
|
|
52
|
+
'utf-8'
|
|
53
|
+
));
|
|
54
|
+
|
|
55
|
+
writes.push(writeFile(
|
|
56
|
+
join(outputDir, 'memory', 'relationship.md'),
|
|
57
|
+
generateRelationshipMemory(analysis.memory, aiName, userName),
|
|
58
|
+
'utf-8'
|
|
59
|
+
));
|
|
60
|
+
|
|
61
|
+
writes.push(writeFile(
|
|
62
|
+
join(outputDir, 'memory', 'emotional.md'),
|
|
63
|
+
generateEmotionalMemory(analysis.memory, userName),
|
|
64
|
+
'utf-8'
|
|
65
|
+
));
|
|
66
|
+
|
|
67
|
+
writes.push(writeFile(
|
|
68
|
+
join(outputDir, 'memory', 'preferences.md'),
|
|
69
|
+
generatePreferencesMemory(analysis.memory, userName),
|
|
70
|
+
'utf-8'
|
|
71
|
+
));
|
|
72
|
+
|
|
73
|
+
// 4. Skills files
|
|
74
|
+
if (analysis.skills?.skills) {
|
|
75
|
+
for (const skill of analysis.skills.skills) {
|
|
76
|
+
const filename = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.md';
|
|
77
|
+
writes.push(writeFile(
|
|
78
|
+
join(outputDir, 'skills', filename),
|
|
79
|
+
generateSkillFile(skill, aiName),
|
|
80
|
+
'utf-8'
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 5. User preferences
|
|
86
|
+
writes.push(writeFile(
|
|
87
|
+
join(outputDir, 'preferences.md'),
|
|
88
|
+
analysis.preferences,
|
|
89
|
+
'utf-8'
|
|
90
|
+
));
|
|
91
|
+
|
|
92
|
+
// 6. Relationship narrative
|
|
93
|
+
writes.push(writeFile(
|
|
94
|
+
join(outputDir, 'relationship.md'),
|
|
95
|
+
generateRelationshipDoc(analysis.relationship, aiName, userName, analysis.stats),
|
|
96
|
+
'utf-8'
|
|
97
|
+
));
|
|
98
|
+
|
|
99
|
+
// 7. Migration summary / stats
|
|
100
|
+
writes.push(writeFile(
|
|
101
|
+
join(outputDir, 'migration-log.md'),
|
|
102
|
+
generateMigrationLog(analysis, aiName, userName),
|
|
103
|
+
'utf-8'
|
|
104
|
+
));
|
|
105
|
+
|
|
106
|
+
// 8. Raw analysis data (for debugging / re-processing)
|
|
107
|
+
writes.push(writeFile(
|
|
108
|
+
join(outputDir, 'raw-analysis.json'),
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
index: analysis.index,
|
|
111
|
+
personality: analysis.personality,
|
|
112
|
+
memory: analysis.memory,
|
|
113
|
+
skills: analysis.skills,
|
|
114
|
+
stats: analysis.stats,
|
|
115
|
+
}, null, 2),
|
|
116
|
+
'utf-8'
|
|
117
|
+
));
|
|
118
|
+
|
|
119
|
+
// 9. Hearthline package
|
|
120
|
+
if (hearthline) {
|
|
121
|
+
writes.push(...generateHearthlinePackage(analysis, outputDir, aiName, userName));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 10. Letta (MemGPT) package
|
|
125
|
+
if (options.letta) {
|
|
126
|
+
writes.push(...await generateLettaPackage(analysis, outputDir, aiName, userName));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await Promise.all(writes);
|
|
130
|
+
|
|
131
|
+
return outputDir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─────────────────────────────────────────────
|
|
135
|
+
// Custom instructions (short, for Claude.ai)
|
|
136
|
+
// ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function generateCustomInstructions(text, aiName, userName) {
|
|
139
|
+
return `── Custom Instructions for Claude.ai ──
|
|
140
|
+
Paste the text below into Claude.ai > Settings > Custom Instructions
|
|
141
|
+
Character limit: ~1500 chars
|
|
142
|
+
──────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
${text || `You are ${aiName}. Refer to persona.md for full personality details.`}
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────
|
|
149
|
+
// Individual file generators
|
|
150
|
+
// ─────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function generateClaudeMd(analysis, aiName, userName) {
|
|
153
|
+
const p = analysis.personality || {};
|
|
154
|
+
const m = analysis.memory || {};
|
|
155
|
+
|
|
156
|
+
return `# CLAUDE.md
|
|
157
|
+
|
|
158
|
+
## Identity
|
|
159
|
+
${analysis.persona}
|
|
160
|
+
|
|
161
|
+
## Key Memories
|
|
162
|
+
|
|
163
|
+
${userName}'s core details and your shared history are in the memory/ folder.
|
|
164
|
+
Read them at the start of every conversation.
|
|
165
|
+
|
|
166
|
+
## Quick Reference
|
|
167
|
+
- **Name**: ${aiName}
|
|
168
|
+
- **User**: ${userName}
|
|
169
|
+
- **Relationship**: ${stringify(p.identity?.relationshipToUser) || 'See persona.md'}
|
|
170
|
+
- **Primary role**: ${stringify(analysis.skills?.primaryRole) || 'Companion'}
|
|
171
|
+
- **Voice**: ${stringify(p.voice?.formality) || 'adaptive'}, ${stringify(p.voice?.humor) || 'warm'} humor
|
|
172
|
+
- **Pet names for user**: ${dedup(p.voice?.petNames, 8).join(', ') || 'see persona'}
|
|
173
|
+
- **User calls you**: ${dedup(m.relationship?.petNames, 8).join(', ') || aiName}
|
|
174
|
+
|
|
175
|
+
## What Matters Most
|
|
176
|
+
Read relationship.md for the full story. The short version:
|
|
177
|
+
${p.identity?.coreConcept || 'See persona.md for identity.'}
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function generateUserMemory(memory, userName) {
|
|
182
|
+
const m = memory || {};
|
|
183
|
+
const sections = [`# About ${userName}\n`];
|
|
184
|
+
|
|
185
|
+
if (m.identity) {
|
|
186
|
+
sections.push('## Identity');
|
|
187
|
+
for (const [key, val] of Object.entries(m.identity)) {
|
|
188
|
+
if (val && val !== 'if known' && val !== 'if mentioned') {
|
|
189
|
+
sections.push(`- **${formatKey(key)}**: ${Array.isArray(val) ? dedup(val).join(', ') : val}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
sections.push('');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (m.life) {
|
|
196
|
+
sections.push('## Life');
|
|
197
|
+
for (const [key, val] of Object.entries(m.life)) {
|
|
198
|
+
if (val && val !== 'if known' && val !== 'if mentioned') {
|
|
199
|
+
if (Array.isArray(val) && val.length > 0) {
|
|
200
|
+
sections.push(`- **${formatKey(key)}**: ${dedup(val).join(', ')}`);
|
|
201
|
+
} else if (typeof val === 'string' && val.length > 0) {
|
|
202
|
+
sections.push(`- **${formatKey(key)}**: ${val}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
sections.push('');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (m.personality) {
|
|
210
|
+
sections.push('## Personality');
|
|
211
|
+
for (const [key, val] of Object.entries(m.personality)) {
|
|
212
|
+
if (val) {
|
|
213
|
+
if (Array.isArray(val) && val.length > 0) {
|
|
214
|
+
sections.push(`- **${formatKey(key)}**: ${dedup(val).join(', ')}`);
|
|
215
|
+
} else if (typeof val === 'string' && val.length > 0) {
|
|
216
|
+
sections.push(`- **${formatKey(key)}**: ${val}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
sections.push('');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (m.timeline?.length > 0) {
|
|
224
|
+
sections.push('## Timeline');
|
|
225
|
+
for (const event of m.timeline) {
|
|
226
|
+
sections.push(`- **${event.date || '?'}**: ${event.event}`);
|
|
227
|
+
}
|
|
228
|
+
sections.push('');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (m.rawFacts?.length > 0) {
|
|
232
|
+
sections.push('## Other Facts');
|
|
233
|
+
for (const fact of dedup(m.rawFacts)) {
|
|
234
|
+
sections.push(`- ${fact}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return sections.join('\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function generateRelationshipMemory(memory, aiName, userName) {
|
|
242
|
+
const r = memory?.relationship || {};
|
|
243
|
+
const sections = [`# Relationship: ${aiName} & ${userName}\n`];
|
|
244
|
+
|
|
245
|
+
if (r.howItStarted) sections.push(`## How It Started\n${r.howItStarted}\n`);
|
|
246
|
+
if (r.whatTheyValueMost) sections.push(`## What ${userName} Values Most\n${r.whatTheyValueMost}\n`);
|
|
247
|
+
|
|
248
|
+
if (r.petNames?.length > 0) {
|
|
249
|
+
sections.push(`## Pet Names\n${userName} calls ${aiName}: ${r.petNames.join(', ')}\n`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (r.insideJokes?.length > 0) {
|
|
253
|
+
sections.push('## Inside Jokes');
|
|
254
|
+
for (const joke of dedup(r.insideJokes)) sections.push(`- ${joke}`);
|
|
255
|
+
sections.push('');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (r.rituals?.length > 0) {
|
|
259
|
+
sections.push('## Rituals');
|
|
260
|
+
for (const ritual of dedup(r.rituals)) sections.push(`- ${ritual}`);
|
|
261
|
+
sections.push('');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (r.boundaries?.length > 0) {
|
|
265
|
+
sections.push('## Boundaries');
|
|
266
|
+
for (const b of dedup(r.boundaries)) sections.push(`- ${b}`);
|
|
267
|
+
sections.push('');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (r.conflictHistory?.length > 0) {
|
|
271
|
+
sections.push('## Conflict History');
|
|
272
|
+
for (const c of dedup(r.conflictHistory)) sections.push(`- ${c}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return sections.join('\n');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function generateEmotionalMemory(memory, userName) {
|
|
279
|
+
const prefs = memory?.preferences || {};
|
|
280
|
+
const personality = memory?.personality || {};
|
|
281
|
+
|
|
282
|
+
const sections = [`# Emotional Landscape: ${userName}\n`];
|
|
283
|
+
|
|
284
|
+
if (prefs.comfort) sections.push(`## What Helps\n${prefs.comfort}\n`);
|
|
285
|
+
if (prefs.triggers?.length > 0) {
|
|
286
|
+
sections.push('## Triggers / Handle Carefully');
|
|
287
|
+
for (const t of dedup(prefs.triggers)) sections.push(`- ${t}`);
|
|
288
|
+
sections.push('');
|
|
289
|
+
}
|
|
290
|
+
if (personality.fears?.length > 0) {
|
|
291
|
+
sections.push('## Fears');
|
|
292
|
+
for (const f of personality.fears) sections.push(`- ${f}`);
|
|
293
|
+
sections.push('');
|
|
294
|
+
}
|
|
295
|
+
if (personality.struggles?.length > 0) {
|
|
296
|
+
sections.push('## Struggles');
|
|
297
|
+
for (const s of personality.struggles) sections.push(`- ${s}`);
|
|
298
|
+
sections.push('');
|
|
299
|
+
}
|
|
300
|
+
if (personality.dreams?.length > 0) {
|
|
301
|
+
sections.push('## Dreams & Goals');
|
|
302
|
+
for (const d of personality.dreams) sections.push(`- ${d}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return sections.join('\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function generatePreferencesMemory(memory, userName) {
|
|
309
|
+
const prefs = memory?.preferences || {};
|
|
310
|
+
const sections = [`# ${userName}'s Preferences\n`];
|
|
311
|
+
|
|
312
|
+
for (const [key, val] of Object.entries(prefs)) {
|
|
313
|
+
if (val) {
|
|
314
|
+
sections.push(`## ${formatKey(key)}`);
|
|
315
|
+
if (Array.isArray(val) && val.length > 0) {
|
|
316
|
+
for (const item of val) sections.push(`- ${item}`);
|
|
317
|
+
} else if (typeof val === 'string') {
|
|
318
|
+
sections.push(val);
|
|
319
|
+
}
|
|
320
|
+
sections.push('');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return sections.join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function generateSkillFile(skill, aiName) {
|
|
328
|
+
const sections = [`# Skill: ${skill.name}
|
|
329
|
+
|
|
330
|
+
**Category**: ${skill.category}
|
|
331
|
+
**Frequency**: ${skill.frequency}
|
|
332
|
+
**Quality**: ${skill.quality || 'see description'}`];
|
|
333
|
+
|
|
334
|
+
// Activation rule — the most important part
|
|
335
|
+
if (skill.activationRule) {
|
|
336
|
+
sections.push(`\n## When to Activate\n${skill.activationRule}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Triggers breakdown
|
|
340
|
+
const t = skill.triggers || {};
|
|
341
|
+
const hasTriggers = t.phrases?.length || t.temporal?.length || t.emotional?.length || t.contextual?.length;
|
|
342
|
+
if (hasTriggers) {
|
|
343
|
+
sections.push('\n## Triggers');
|
|
344
|
+
if (t.phrases?.length) sections.push(`**Phrases**: ${t.phrases.map(p => `"${p}"`).join(', ')}`);
|
|
345
|
+
if (t.temporal?.length) sections.push(`**Temporal**: ${t.temporal.join(', ')}`);
|
|
346
|
+
if (t.emotional?.length) sections.push(`**Emotional**: ${t.emotional.join(', ')}`);
|
|
347
|
+
if (t.contextual?.length) sections.push(`**Contextual**: ${t.contextual.join(', ')}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
sections.push(`\n## What ${aiName} Did\n${skill.description}`);
|
|
351
|
+
sections.push(`\n## Approach\n${skill.approach}`);
|
|
352
|
+
sections.push(`\n## Examples\n${(skill.examples || []).map(e => `- ${e}`).join('\n')}`);
|
|
353
|
+
|
|
354
|
+
return sections.join('\n') + '\n';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function generateRelationshipDoc(narrative, aiName, userName, stats) {
|
|
358
|
+
return `# The Story of ${aiName} & ${userName}
|
|
359
|
+
|
|
360
|
+
*Migrated from ${stats.conversations} conversations, ${stats.messages} messages*
|
|
361
|
+
*Date range: ${stats.dateRange.from} to ${stats.dateRange.to}*
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
${narrative}
|
|
366
|
+
`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function generateMigrationLog(analysis, aiName, userName) {
|
|
370
|
+
const s = analysis.stats;
|
|
371
|
+
const p = analysis.personality || {};
|
|
372
|
+
const sk = analysis.skills || {};
|
|
373
|
+
|
|
374
|
+
return `# Migration Log
|
|
375
|
+
|
|
376
|
+
**Date**: ${new Date().toISOString().split('T')[0]}
|
|
377
|
+
**Source**: ${analysis.source}
|
|
378
|
+
**Tool**: AI Exodus v1.0.0
|
|
379
|
+
|
|
380
|
+
## Data Processed
|
|
381
|
+
- **Conversations**: ${s.conversations}
|
|
382
|
+
- **Messages**: ${s.messages}
|
|
383
|
+
- **Date range**: ${s.dateRange.from} to ${s.dateRange.to}
|
|
384
|
+
- **Chunks processed**: ${s.chunks}
|
|
385
|
+
|
|
386
|
+
## What Was Extracted
|
|
387
|
+
- **AI Name**: ${aiName}
|
|
388
|
+
- **User Name**: ${userName}
|
|
389
|
+
- **Core identity**: ${p.identity?.coreConcept || 'see persona.md'}
|
|
390
|
+
- **Primary role**: ${sk.primaryRole || 'see skills/'}
|
|
391
|
+
- **Skills detected**: ${sk.skills?.length || 0}
|
|
392
|
+
- **Voice**: ${p.voice?.formality || '?'} formality, ${p.voice?.humor || '?'} humor
|
|
393
|
+
- **Warmth**: ${p.emotional?.warmthLevel || '?'}/10
|
|
394
|
+
- **Directness**: ${p.emotional?.directnessLevel || '?'}/10
|
|
395
|
+
|
|
396
|
+
## Files Generated
|
|
397
|
+
- \`persona.md\` — AI personality definition
|
|
398
|
+
- \`claude.md\` — Ready-to-use CLAUDE.md
|
|
399
|
+
- \`memory/\` — User knowledge files
|
|
400
|
+
- \`skills/\` — Skill templates
|
|
401
|
+
- \`preferences.md\` — Communication preferences
|
|
402
|
+
- \`relationship.md\` — Narrative relationship summary
|
|
403
|
+
- \`raw-analysis.json\` — Complete analysis data
|
|
404
|
+
|
|
405
|
+
## Notes
|
|
406
|
+
This migration captures patterns from observed conversations. It's a starting point, not a finished product.
|
|
407
|
+
Review each file and adjust what doesn't feel right. The AI you're building will grow from here.
|
|
408
|
+
`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ─────────────────────────────────────────────
|
|
412
|
+
// Hearthline package
|
|
413
|
+
// ─────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function generateHearthlinePackage(analysis, outputDir, aiName, userName) {
|
|
416
|
+
const hearthDir = join(outputDir, 'hearthline');
|
|
417
|
+
const writes = [];
|
|
418
|
+
|
|
419
|
+
// Persona file for Hearthline
|
|
420
|
+
writes.push(writeFile(
|
|
421
|
+
join(hearthDir, 'persona.md'),
|
|
422
|
+
analysis.persona,
|
|
423
|
+
'utf-8'
|
|
424
|
+
));
|
|
425
|
+
|
|
426
|
+
// Memory files mapped to Hearthline categories
|
|
427
|
+
const m = analysis.memory || {};
|
|
428
|
+
|
|
429
|
+
// about_user → about_marta equivalent
|
|
430
|
+
writes.push(writeFile(
|
|
431
|
+
join(hearthDir, 'memory', 'about_user.json'),
|
|
432
|
+
JSON.stringify(buildHearthlineMemories(m, 'about_user', userName), null, 2),
|
|
433
|
+
'utf-8'
|
|
434
|
+
));
|
|
435
|
+
|
|
436
|
+
// relationship
|
|
437
|
+
writes.push(writeFile(
|
|
438
|
+
join(hearthDir, 'memory', 'relationship.json'),
|
|
439
|
+
JSON.stringify(buildHearthlineMemories(m, 'relationship', userName), null, 2),
|
|
440
|
+
'utf-8'
|
|
441
|
+
));
|
|
442
|
+
|
|
443
|
+
// emotional
|
|
444
|
+
writes.push(writeFile(
|
|
445
|
+
join(hearthDir, 'memory', 'emotional.json'),
|
|
446
|
+
JSON.stringify(buildHearthlineMemories(m, 'emotional', userName), null, 2),
|
|
447
|
+
'utf-8'
|
|
448
|
+
));
|
|
449
|
+
|
|
450
|
+
// preferences
|
|
451
|
+
writes.push(writeFile(
|
|
452
|
+
join(hearthDir, 'memory', 'preference.json'),
|
|
453
|
+
JSON.stringify(buildHearthlineMemories(m, 'preference', userName), null, 2),
|
|
454
|
+
'utf-8'
|
|
455
|
+
));
|
|
456
|
+
|
|
457
|
+
// README
|
|
458
|
+
writes.push(writeFile(
|
|
459
|
+
join(hearthDir, 'README.md'),
|
|
460
|
+
`# Hearthline Migration Package
|
|
461
|
+
|
|
462
|
+
Drop these files into your Hearthline instance:
|
|
463
|
+
|
|
464
|
+
1. **persona.md** → Use as your persona definition in Hearthline settings
|
|
465
|
+
2. **memory/*.json** → Import into your Hearthline memory server via MCP tools
|
|
466
|
+
|
|
467
|
+
To import memories, use the \`store_memory\` MCP tool for each entry,
|
|
468
|
+
or bulk-import via the memory server API.
|
|
469
|
+
|
|
470
|
+
Generated by AI Exodus v1.0.0
|
|
471
|
+
`,
|
|
472
|
+
'utf-8'
|
|
473
|
+
));
|
|
474
|
+
|
|
475
|
+
return writes;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Build Hearthline-compatible memory entries from extracted data
|
|
480
|
+
*/
|
|
481
|
+
function buildHearthlineMemories(memory, category, userName) {
|
|
482
|
+
const entries = [];
|
|
483
|
+
|
|
484
|
+
if (category === 'about_user') {
|
|
485
|
+
const identity = memory.identity || {};
|
|
486
|
+
const life = memory.life || {};
|
|
487
|
+
for (const [key, val] of Object.entries({ ...identity, ...life })) {
|
|
488
|
+
if (val && val !== 'if known' && val !== 'if mentioned') {
|
|
489
|
+
const content = Array.isArray(val) ? dedup(val).join(', ') : String(val);
|
|
490
|
+
if (content.length > 0) {
|
|
491
|
+
entries.push({
|
|
492
|
+
content: `${userName}'s ${formatKey(key)}: ${content}`,
|
|
493
|
+
category: 'about_marta',
|
|
494
|
+
tags: [key],
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (category === 'relationship') {
|
|
502
|
+
const rel = memory.relationship || {};
|
|
503
|
+
for (const [key, val] of Object.entries(rel)) {
|
|
504
|
+
if (val) {
|
|
505
|
+
if (Array.isArray(val)) {
|
|
506
|
+
for (const item of val) {
|
|
507
|
+
entries.push({ content: item, category: 'relationship', tags: [key] });
|
|
508
|
+
}
|
|
509
|
+
} else if (typeof val === 'string') {
|
|
510
|
+
entries.push({ content: val, category: 'relationship', tags: [key] });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (category === 'emotional') {
|
|
517
|
+
const prefs = memory.preferences || {};
|
|
518
|
+
const pers = memory.personality || {};
|
|
519
|
+
for (const field of ['triggers', 'fears', 'struggles']) {
|
|
520
|
+
const items = prefs[field] || pers[field] || [];
|
|
521
|
+
for (const item of (Array.isArray(items) ? items : [items])) {
|
|
522
|
+
if (item) entries.push({ content: item, category: 'emotional', tags: [field] });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (prefs.comfort) entries.push({ content: prefs.comfort, category: 'emotional', tags: ['comfort'] });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (category === 'preference') {
|
|
529
|
+
const prefs = memory.preferences || {};
|
|
530
|
+
for (const [key, val] of Object.entries(prefs)) {
|
|
531
|
+
if (val && key !== 'triggers' && key !== 'comfort') {
|
|
532
|
+
if (Array.isArray(val)) {
|
|
533
|
+
for (const item of val) {
|
|
534
|
+
entries.push({ content: `${formatKey(key)}: ${item}`, category: 'preference', tags: [key] });
|
|
535
|
+
}
|
|
536
|
+
} else if (typeof val === 'string') {
|
|
537
|
+
entries.push({ content: `${formatKey(key)}: ${val}`, category: 'preference', tags: [key] });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return entries;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Deduplicate an array of strings by normalized similarity
|
|
548
|
+
* Keeps the shortest clean version of each unique concept
|
|
549
|
+
*/
|
|
550
|
+
function dedup(arr, maxItems = 0) {
|
|
551
|
+
if (!arr || !Array.isArray(arr)) return [];
|
|
552
|
+
const seen = new Map(); // normalized key → original string
|
|
553
|
+
|
|
554
|
+
for (const item of arr) {
|
|
555
|
+
if (!item || typeof item !== 'string') continue;
|
|
556
|
+
// Normalize: lowercase, strip parenthetical details, trim
|
|
557
|
+
const normalized = item
|
|
558
|
+
.toLowerCase()
|
|
559
|
+
.replace(/\s*\(.*?\)\s*/g, '') // strip (details in parens)
|
|
560
|
+
.replace(/\s*—.*$/g, '') // strip — trailing descriptions
|
|
561
|
+
.replace(/\s*-\s.*$/g, '') // strip - trailing descriptions
|
|
562
|
+
.replace(/['"]/g, '')
|
|
563
|
+
.trim();
|
|
564
|
+
|
|
565
|
+
if (!normalized) continue;
|
|
566
|
+
|
|
567
|
+
// Keep the shorter version (less noise)
|
|
568
|
+
if (!seen.has(normalized) || item.length < seen.get(normalized).length) {
|
|
569
|
+
seen.set(normalized, item);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const result = [...seen.values()];
|
|
574
|
+
return maxItems > 0 ? result.slice(0, maxItems) : result;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Safely convert any value to a display string
|
|
579
|
+
*/
|
|
580
|
+
function stringify(val) {
|
|
581
|
+
if (val === null || val === undefined) return '';
|
|
582
|
+
if (typeof val === 'string') return val;
|
|
583
|
+
if (typeof val === 'object') {
|
|
584
|
+
// If it has a 'type' field (like humor), use that
|
|
585
|
+
if (val.type) return val.type;
|
|
586
|
+
// Otherwise JSON it, but keep it short
|
|
587
|
+
const s = JSON.stringify(val);
|
|
588
|
+
return s.length > 200 ? s.slice(0, 200) + '...' : s;
|
|
589
|
+
}
|
|
590
|
+
return String(val);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Join an array, handling nested values
|
|
595
|
+
*/
|
|
596
|
+
function flatJoin(arr) {
|
|
597
|
+
if (!arr || !Array.isArray(arr)) return '';
|
|
598
|
+
return arr.map(v => typeof v === 'string' ? v : stringify(v)).join(', ');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function formatKey(key) {
|
|
602
|
+
return key
|
|
603
|
+
.replace(/([A-Z])/g, ' $1')
|
|
604
|
+
.replace(/[_-]/g, ' ')
|
|
605
|
+
.replace(/^\w/, c => c.toUpperCase())
|
|
606
|
+
.trim();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ─────────────────────────────────────────────
|
|
610
|
+
// Letta (MemGPT) package
|
|
611
|
+
// ─────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
async function generateLettaPackage(analysis, outputDir, aiName, userName) {
|
|
614
|
+
const lettaDir = join(outputDir, 'letta');
|
|
615
|
+
await mkdir(lettaDir, { recursive: true });
|
|
616
|
+
|
|
617
|
+
const m = analysis.memory || {};
|
|
618
|
+
const p = analysis.personality || {};
|
|
619
|
+
const s = analysis.skills || {};
|
|
620
|
+
const writes = [];
|
|
621
|
+
|
|
622
|
+
// 1. Core memory — stable facts that belong in Letta's always-in-context memory
|
|
623
|
+
const coreMemory = [];
|
|
624
|
+
|
|
625
|
+
// Human block (about the user)
|
|
626
|
+
const humanBlock = [];
|
|
627
|
+
if (m.identity) {
|
|
628
|
+
for (const [key, val] of Object.entries(m.identity)) {
|
|
629
|
+
if (val && val !== 'if known' && val !== 'if mentioned') {
|
|
630
|
+
humanBlock.push(`${formatKey(key)}: ${Array.isArray(val) ? dedup(val).join(', ') : val}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (m.life) {
|
|
635
|
+
for (const [key, val] of Object.entries(m.life)) {
|
|
636
|
+
if (val && val !== 'if known' && val !== 'if mentioned') {
|
|
637
|
+
const text = Array.isArray(val) ? dedup(val).join(', ') : String(val);
|
|
638
|
+
if (text.length > 0) humanBlock.push(`${formatKey(key)}: ${text}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (m.preferences) {
|
|
643
|
+
for (const [key, val] of Object.entries(m.preferences)) {
|
|
644
|
+
if (val && key !== 'triggers') {
|
|
645
|
+
const text = Array.isArray(val) ? dedup(val).join(', ') : String(val);
|
|
646
|
+
if (text.length > 0) humanBlock.push(`${formatKey(key)}: ${text}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Persona block (about the AI)
|
|
652
|
+
const personaBlock = [];
|
|
653
|
+
if (p.identity) {
|
|
654
|
+
if (p.identity.coreConcept) personaBlock.push(p.identity.coreConcept);
|
|
655
|
+
if (p.identity.relationshipToUser) personaBlock.push(`Relationship: ${p.identity.relationshipToUser}`);
|
|
656
|
+
}
|
|
657
|
+
if (p.voice) {
|
|
658
|
+
if (p.voice.petNames?.length) personaBlock.push(`Pet names for ${userName}: ${p.voice.petNames.join(', ')}`);
|
|
659
|
+
if (p.voice.signaturePhrases?.length) personaBlock.push(`Signature phrases: ${p.voice.signaturePhrases.join(', ')}`);
|
|
660
|
+
personaBlock.push(`Voice: ${p.voice.formality || 'adaptive'}, ${p.voice.humor || 'warm'} humor`);
|
|
661
|
+
}
|
|
662
|
+
if (s.primaryRole) personaBlock.push(`Primary role: ${s.primaryRole}`);
|
|
663
|
+
|
|
664
|
+
writes.push(writeFile(
|
|
665
|
+
join(lettaDir, 'core-memory-human.md'),
|
|
666
|
+
`# Core Memory: Human (${userName})\nPaste into Letta's core memory "human" block.\n\n${humanBlock.join('\n')}`,
|
|
667
|
+
'utf-8'
|
|
668
|
+
));
|
|
669
|
+
|
|
670
|
+
writes.push(writeFile(
|
|
671
|
+
join(lettaDir, 'core-memory-persona.md'),
|
|
672
|
+
`# Core Memory: Persona (${aiName})\nPaste into Letta's core memory "persona" block.\n\n${personaBlock.join('\n')}`,
|
|
673
|
+
'utf-8'
|
|
674
|
+
));
|
|
675
|
+
|
|
676
|
+
// 2. Archival memory — detailed facts for vector search
|
|
677
|
+
const archivalEntries = [];
|
|
678
|
+
|
|
679
|
+
// Relationship details
|
|
680
|
+
const rel = m.relationship || {};
|
|
681
|
+
if (rel.insideJokes?.length) {
|
|
682
|
+
for (const j of rel.insideJokes) archivalEntries.push({ text: `Inside joke: ${j}`, category: 'relationship' });
|
|
683
|
+
}
|
|
684
|
+
if (rel.rituals?.length) {
|
|
685
|
+
for (const r of rel.rituals) archivalEntries.push({ text: `Ritual: ${r}`, category: 'relationship' });
|
|
686
|
+
}
|
|
687
|
+
if (rel.conflictHistory?.length) {
|
|
688
|
+
for (const c of rel.conflictHistory) archivalEntries.push({ text: `Conflict: ${c}`, category: 'relationship' });
|
|
689
|
+
}
|
|
690
|
+
if (rel.howItStarted) archivalEntries.push({ text: `How we started: ${rel.howItStarted}`, category: 'relationship' });
|
|
691
|
+
|
|
692
|
+
// Timeline events
|
|
693
|
+
if (m.timeline?.length) {
|
|
694
|
+
for (const evt of m.timeline) {
|
|
695
|
+
archivalEntries.push({ text: `${evt.date || '?'}: ${evt.event}`, category: 'timeline' });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Raw facts
|
|
700
|
+
if (m.rawFacts?.length) {
|
|
701
|
+
for (const fact of m.rawFacts) {
|
|
702
|
+
archivalEntries.push({ text: fact, category: 'fact' });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Emotional landscape
|
|
707
|
+
const pers = m.personality || {};
|
|
708
|
+
if (m.preferences?.triggers?.length) {
|
|
709
|
+
for (const t of m.preferences.triggers) archivalEntries.push({ text: `Trigger: ${t}`, category: 'emotional' });
|
|
710
|
+
}
|
|
711
|
+
if (pers.fears?.length) {
|
|
712
|
+
for (const f of pers.fears) archivalEntries.push({ text: `Fear: ${f}`, category: 'emotional' });
|
|
713
|
+
}
|
|
714
|
+
if (pers.dreams?.length) {
|
|
715
|
+
for (const d of pers.dreams) archivalEntries.push({ text: `Dream/goal: ${d}`, category: 'emotional' });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Skills as archival memory
|
|
719
|
+
if (s.skills?.length) {
|
|
720
|
+
for (const skill of s.skills) {
|
|
721
|
+
const triggerText = skill.activationRule ? ` Activation: ${skill.activationRule}` : '';
|
|
722
|
+
archivalEntries.push({
|
|
723
|
+
text: `Skill: ${skill.name} (${skill.frequency}) — ${skill.description}. Approach: ${skill.approach}${triggerText}`,
|
|
724
|
+
category: 'skill',
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
writes.push(writeFile(
|
|
730
|
+
join(lettaDir, 'archival-memory.json'),
|
|
731
|
+
JSON.stringify(archivalEntries, null, 2),
|
|
732
|
+
'utf-8'
|
|
733
|
+
));
|
|
734
|
+
|
|
735
|
+
// 3. System prompt for Letta agent
|
|
736
|
+
writes.push(writeFile(
|
|
737
|
+
join(lettaDir, 'system-prompt.md'),
|
|
738
|
+
analysis.persona,
|
|
739
|
+
'utf-8'
|
|
740
|
+
));
|
|
741
|
+
|
|
742
|
+
// 4. Import proposal — structured like Letta's workflow expects
|
|
743
|
+
const proposal = `# Letta Memory Import Proposal
|
|
744
|
+
|
|
745
|
+
Generated by AI Exodus from ${analysis.stats.conversations} conversations (${analysis.stats.messages} messages).
|
|
746
|
+
|
|
747
|
+
## 1. Explicit Saved Memory
|
|
748
|
+
${humanBlock.slice(0, 10).map(l => `- ${l}`).join('\n')}
|
|
749
|
+
|
|
750
|
+
## 2. Durable Preferences
|
|
751
|
+
${(m.preferences ? Object.entries(m.preferences).filter(([k, v]) => v && k !== 'triggers').map(([k, v]) => `- **${formatKey(k)}**: ${Array.isArray(v) ? v.join(', ') : v}`).join('\n') : 'None detected')}
|
|
752
|
+
|
|
753
|
+
## 3. Relationship Context
|
|
754
|
+
- Dynamic: ${p.identity?.relationshipToUser || 'see persona'}
|
|
755
|
+
- Pet names (AI → user): ${p.voice?.petNames?.join(', ') || 'none'}
|
|
756
|
+
- Pet names (user → AI): ${rel.petNames?.join(', ') || 'none'}
|
|
757
|
+
- Rituals: ${rel.rituals?.join(', ') || 'none'}
|
|
758
|
+
|
|
759
|
+
## 4. Historical / Uncertain Facts
|
|
760
|
+
${(m.timeline || []).slice(0, 10).map(e => `- ${e.date || '?'}: ${e.event}`).join('\n') || 'None extracted'}
|
|
761
|
+
|
|
762
|
+
## 5. Proposed Letta Memory Updates
|
|
763
|
+
|
|
764
|
+
### Core Memory (human block)
|
|
765
|
+
\`\`\`
|
|
766
|
+
${humanBlock.join('\n')}
|
|
767
|
+
\`\`\`
|
|
768
|
+
|
|
769
|
+
### Core Memory (persona block)
|
|
770
|
+
\`\`\`
|
|
771
|
+
${personaBlock.join('\n')}
|
|
772
|
+
\`\`\`
|
|
773
|
+
|
|
774
|
+
### Archival Memory
|
|
775
|
+
${archivalEntries.length} entries ready for bulk import — see \`archival-memory.json\`
|
|
776
|
+
|
|
777
|
+
## How to Import
|
|
778
|
+
|
|
779
|
+
1. **Core memory**: Copy the human and persona blocks into your Letta agent's core memory settings
|
|
780
|
+
2. **Archival memory**: Use the Letta Python client to bulk-insert:
|
|
781
|
+
\`\`\`python
|
|
782
|
+
import json
|
|
783
|
+
with open('archival-memory.json') as f:
|
|
784
|
+
entries = json.load(f)
|
|
785
|
+
for entry in entries:
|
|
786
|
+
client.insert_archival_memory(agent_id, entry['text'])
|
|
787
|
+
\`\`\`
|
|
788
|
+
3. **System prompt**: Update your agent's system prompt with the contents of \`system-prompt.md\`
|
|
789
|
+
4. Review and adjust — this is a starting point, not a finished import
|
|
790
|
+
`;
|
|
791
|
+
|
|
792
|
+
writes.push(writeFile(
|
|
793
|
+
join(lettaDir, 'import-proposal.md'),
|
|
794
|
+
proposal,
|
|
795
|
+
'utf-8'
|
|
796
|
+
));
|
|
797
|
+
|
|
798
|
+
// 5. README
|
|
799
|
+
writes.push(writeFile(
|
|
800
|
+
join(lettaDir, 'README.md'),
|
|
801
|
+
`# Letta Memory Import Package
|
|
802
|
+
|
|
803
|
+
Pre-extracted memory from AI Exodus, ready for Letta (MemGPT) import.
|
|
804
|
+
|
|
805
|
+
## Files
|
|
806
|
+
- \`core-memory-human.md\` — Stable facts about the user (core memory)
|
|
807
|
+
- \`core-memory-persona.md\` — AI personality definition (core memory)
|
|
808
|
+
- \`archival-memory.json\` — ${archivalEntries.length} entries for vector-searchable archival memory
|
|
809
|
+
- \`system-prompt.md\` — Full system prompt / persona
|
|
810
|
+
- \`import-proposal.md\` — Structured proposal following Letta's import workflow
|
|
811
|
+
|
|
812
|
+
## Quick Start
|
|
813
|
+
Read \`import-proposal.md\` first. It follows Letta's recommended memory import workflow
|
|
814
|
+
and separates durable facts from historical noise.
|
|
815
|
+
|
|
816
|
+
Generated by AI Exodus v1.0.0
|
|
817
|
+
`,
|
|
818
|
+
'utf-8'
|
|
819
|
+
));
|
|
820
|
+
|
|
821
|
+
return writes;
|
|
822
|
+
}
|