kernelbot 1.0.37 → 1.0.38
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/kernel.js +389 -23
- package/config.example.yaml +17 -0
- package/package.json +2 -1
- package/src/agent.js +355 -82
- package/src/bot.js +724 -12
- package/src/character.js +406 -0
- package/src/characters/builder.js +174 -0
- package/src/characters/builtins.js +421 -0
- package/src/conversation.js +17 -2
- package/src/dashboard/agents.css +469 -0
- package/src/dashboard/agents.html +184 -0
- package/src/dashboard/agents.js +873 -0
- package/src/dashboard/dashboard.css +281 -0
- package/src/dashboard/dashboard.js +579 -0
- package/src/dashboard/index.html +366 -0
- package/src/dashboard/server.js +521 -0
- package/src/dashboard/shared.css +700 -0
- package/src/dashboard/shared.js +209 -0
- package/src/life/engine.js +28 -20
- package/src/life/evolution.js +7 -5
- package/src/life/journal.js +5 -4
- package/src/life/memory.js +12 -9
- package/src/life/share-queue.js +7 -5
- package/src/prompts/orchestrator.js +76 -14
- package/src/prompts/workers.js +22 -0
- package/src/self.js +17 -5
- package/src/services/linkedin-api.js +190 -0
- package/src/services/stt.js +8 -2
- package/src/services/tts.js +32 -2
- package/src/services/x-api.js +141 -0
- package/src/swarm/worker-registry.js +7 -0
- package/src/tools/categories.js +4 -0
- package/src/tools/index.js +6 -0
- package/src/tools/linkedin.js +264 -0
- package/src/tools/orchestrator-tools.js +337 -2
- package/src/tools/x.js +256 -0
- package/src/utils/config.js +104 -57
- package/src/utils/display.js +73 -12
- package/src/utils/temporal-awareness.js +24 -10
package/src/character.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync, rmSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname } from 'path';
|
|
6
|
+
import { getLogger } from './utils/logger.js';
|
|
7
|
+
import { BUILTIN_CHARACTERS } from './characters/builtins.js';
|
|
8
|
+
import { SelfManager } from './self.js';
|
|
9
|
+
import { MemoryManager } from './life/memory.js';
|
|
10
|
+
import { JournalManager } from './life/journal.js';
|
|
11
|
+
import { ShareQueue } from './life/share-queue.js';
|
|
12
|
+
import { EvolutionTracker } from './life/evolution.js';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const KERNELBOT_DIR = join(homedir(), '.kernelbot');
|
|
16
|
+
const CHARACTERS_DIR = join(KERNELBOT_DIR, 'characters');
|
|
17
|
+
const REGISTRY_FILE = join(CHARACTERS_DIR, 'registry.json');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_REGISTRY = {
|
|
20
|
+
activeCharacterId: 'kernel',
|
|
21
|
+
characters: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class CharacterManager {
|
|
25
|
+
constructor() {
|
|
26
|
+
this._registry = null;
|
|
27
|
+
this.needsOnboarding = false;
|
|
28
|
+
mkdirSync(CHARACTERS_DIR, { recursive: true });
|
|
29
|
+
this._load();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Registry Persistence ─────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
_load() {
|
|
35
|
+
const logger = getLogger();
|
|
36
|
+
|
|
37
|
+
if (existsSync(REGISTRY_FILE)) {
|
|
38
|
+
try {
|
|
39
|
+
this._registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf-8'));
|
|
40
|
+
logger.debug(`[CharacterManager] Loaded registry: ${Object.keys(this._registry.characters).length} characters`);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
logger.warn(`[CharacterManager] Failed to parse registry — resetting to defaults: ${err.message}`);
|
|
43
|
+
this._registry = { ...DEFAULT_REGISTRY, characters: {} };
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// No registry exists — check if we need migration or fresh onboarding
|
|
49
|
+
const legacySelfDir = join(KERNELBOT_DIR, 'self');
|
|
50
|
+
if (existsSync(legacySelfDir)) {
|
|
51
|
+
// Existing installation — migrate to kernel character
|
|
52
|
+
logger.info('[CharacterManager] Legacy data detected — migrating to kernel character');
|
|
53
|
+
this._migrateToKernel();
|
|
54
|
+
} else {
|
|
55
|
+
// Fresh install — needs onboarding
|
|
56
|
+
logger.info('[CharacterManager] Fresh install — onboarding needed');
|
|
57
|
+
this._registry = { ...DEFAULT_REGISTRY, characters: {} };
|
|
58
|
+
this.needsOnboarding = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_save() {
|
|
63
|
+
const logger = getLogger();
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(this._registry, null, 2), 'utf-8');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.error(`[CharacterManager] Failed to save registry: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Migration ────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
_migrateToKernel() {
|
|
74
|
+
const logger = getLogger();
|
|
75
|
+
const kernelDir = join(CHARACTERS_DIR, 'kernel');
|
|
76
|
+
mkdirSync(kernelDir, { recursive: true });
|
|
77
|
+
|
|
78
|
+
// Copy self/ → characters/kernel/self/
|
|
79
|
+
const legacySelfDir = join(KERNELBOT_DIR, 'self');
|
|
80
|
+
const targetSelfDir = join(kernelDir, 'self');
|
|
81
|
+
if (existsSync(legacySelfDir) && !existsSync(targetSelfDir)) {
|
|
82
|
+
cpSync(legacySelfDir, targetSelfDir, { recursive: true });
|
|
83
|
+
logger.info('[CharacterManager] Migrated self/ → characters/kernel/self/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Copy life/ → characters/kernel/life/
|
|
87
|
+
const legacyLifeDir = join(KERNELBOT_DIR, 'life');
|
|
88
|
+
const targetLifeDir = join(kernelDir, 'life');
|
|
89
|
+
if (existsSync(legacyLifeDir) && !existsSync(targetLifeDir)) {
|
|
90
|
+
cpSync(legacyLifeDir, targetLifeDir, { recursive: true });
|
|
91
|
+
logger.info('[CharacterManager] Migrated life/ → characters/kernel/life/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Copy conversations.json → characters/kernel/conversations.json
|
|
95
|
+
// Re-key all conversation and skill entries with "kernel:" prefix
|
|
96
|
+
// so they match the new _chatKey() scoping in agent.js
|
|
97
|
+
const legacyConvFile = join(KERNELBOT_DIR, 'conversations.json');
|
|
98
|
+
const targetConvFile = join(kernelDir, 'conversations.json');
|
|
99
|
+
if (existsSync(legacyConvFile) && !existsSync(targetConvFile)) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = JSON.parse(readFileSync(legacyConvFile, 'utf-8'));
|
|
102
|
+
const migrated = {};
|
|
103
|
+
|
|
104
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
105
|
+
if (key === '_skills') {
|
|
106
|
+
// Re-key skills: "12345" → "kernel:12345"
|
|
107
|
+
const migratedSkills = {};
|
|
108
|
+
for (const [skillKey, skillId] of Object.entries(value)) {
|
|
109
|
+
const newKey = skillKey.startsWith('kernel:') || skillKey.startsWith('__life__') ? skillKey : `kernel:${skillKey}`;
|
|
110
|
+
migratedSkills[newKey] = skillId;
|
|
111
|
+
}
|
|
112
|
+
migrated._skills = migratedSkills;
|
|
113
|
+
} else if (key.startsWith('__life__')) {
|
|
114
|
+
// Re-key life engine chat: "__life__" → "__life__:kernel"
|
|
115
|
+
migrated[key === '__life__' ? '__life__:kernel' : key] = value;
|
|
116
|
+
} else {
|
|
117
|
+
// Re-key user chats: "12345" → "kernel:12345"
|
|
118
|
+
const newKey = key.startsWith('kernel:') ? key : `kernel:${key}`;
|
|
119
|
+
migrated[newKey] = value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
writeFileSync(targetConvFile, JSON.stringify(migrated, null, 2), 'utf-8');
|
|
124
|
+
logger.info('[CharacterManager] Migrated conversations.json (re-keyed with kernel: prefix)');
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Fallback: just copy as-is if parsing fails
|
|
127
|
+
cpSync(legacyConvFile, targetConvFile);
|
|
128
|
+
logger.warn(`[CharacterManager] Migrated conversations.json (raw copy, re-key failed: ${err.message})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Copy persona.md from source
|
|
133
|
+
const defaultPersonaMd = join(__dirname, 'prompts', 'persona.md');
|
|
134
|
+
const targetPersonaMd = join(kernelDir, 'persona.md');
|
|
135
|
+
if (existsSync(defaultPersonaMd) && !existsSync(targetPersonaMd)) {
|
|
136
|
+
cpSync(defaultPersonaMd, targetPersonaMd);
|
|
137
|
+
logger.info('[CharacterManager] Copied persona.md to kernel character');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Create profile.json
|
|
141
|
+
const profileFile = join(kernelDir, 'profile.json');
|
|
142
|
+
if (!existsSync(profileFile)) {
|
|
143
|
+
const profile = {
|
|
144
|
+
id: 'kernel',
|
|
145
|
+
type: 'legacy',
|
|
146
|
+
name: 'Kernel',
|
|
147
|
+
origin: 'Original',
|
|
148
|
+
age: 'Young AI',
|
|
149
|
+
emoji: '\uD83D\uDC9C',
|
|
150
|
+
tagline: 'Your personal AI, always evolving.',
|
|
151
|
+
asciiArt: null,
|
|
152
|
+
createdAt: Date.now(),
|
|
153
|
+
lastActiveAt: Date.now(),
|
|
154
|
+
evolutionHistory: [],
|
|
155
|
+
};
|
|
156
|
+
writeFileSync(profileFile, JSON.stringify(profile, null, 2), 'utf-8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Build registry
|
|
160
|
+
this._registry = {
|
|
161
|
+
activeCharacterId: 'kernel',
|
|
162
|
+
characters: {
|
|
163
|
+
kernel: {
|
|
164
|
+
id: 'kernel',
|
|
165
|
+
type: 'legacy',
|
|
166
|
+
name: 'Kernel',
|
|
167
|
+
origin: 'Original',
|
|
168
|
+
age: 'Young AI',
|
|
169
|
+
emoji: '\uD83D\uDC9C',
|
|
170
|
+
tagline: 'Your personal AI, always evolving.',
|
|
171
|
+
asciiArt: null,
|
|
172
|
+
createdAt: Date.now(),
|
|
173
|
+
lastActiveAt: Date.now(),
|
|
174
|
+
evolutionHistory: [],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
this._save();
|
|
179
|
+
logger.info('[CharacterManager] Migration complete — kernel character active');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Public API ──────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
getActiveCharacterId() {
|
|
185
|
+
return this._registry.activeCharacterId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
setActiveCharacter(id) {
|
|
189
|
+
const logger = getLogger();
|
|
190
|
+
if (!this._registry.characters[id]) {
|
|
191
|
+
throw new Error(`Character not found: ${id}`);
|
|
192
|
+
}
|
|
193
|
+
this._registry.activeCharacterId = id;
|
|
194
|
+
this._registry.characters[id].lastActiveAt = Date.now();
|
|
195
|
+
this._save();
|
|
196
|
+
logger.info(`[CharacterManager] Active character set to: ${id}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getCharacter(id) {
|
|
200
|
+
return this._registry.characters[id] || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
listCharacters() {
|
|
204
|
+
return Object.values(this._registry.characters);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
getCharacterDir(id) {
|
|
208
|
+
return join(CHARACTERS_DIR, id);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getPersonaMd(id) {
|
|
212
|
+
const personaFile = join(this.getCharacterDir(id), 'persona.md');
|
|
213
|
+
if (existsSync(personaFile)) {
|
|
214
|
+
return readFileSync(personaFile, 'utf-8').trim();
|
|
215
|
+
}
|
|
216
|
+
// Fallback for kernel: use default persona.md
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Install a character — creates full directory tree and files.
|
|
222
|
+
* Used for both built-in characters and custom characters.
|
|
223
|
+
*/
|
|
224
|
+
addCharacter(profile, personaMd = null, selfDefaults = null) {
|
|
225
|
+
const logger = getLogger();
|
|
226
|
+
const id = profile.id;
|
|
227
|
+
const dir = this.getCharacterDir(id);
|
|
228
|
+
|
|
229
|
+
// Create directory structure
|
|
230
|
+
mkdirSync(join(dir, 'self'), { recursive: true });
|
|
231
|
+
mkdirSync(join(dir, 'life', 'memories', 'episodic'), { recursive: true });
|
|
232
|
+
mkdirSync(join(dir, 'life', 'memories', 'semantic'), { recursive: true });
|
|
233
|
+
mkdirSync(join(dir, 'life', 'journals'), { recursive: true });
|
|
234
|
+
|
|
235
|
+
// Write persona.md
|
|
236
|
+
if (personaMd) {
|
|
237
|
+
writeFileSync(join(dir, 'persona.md'), personaMd, 'utf-8');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Write profile.json
|
|
241
|
+
const fullProfile = {
|
|
242
|
+
id,
|
|
243
|
+
type: profile.type || 'custom',
|
|
244
|
+
name: profile.name,
|
|
245
|
+
origin: profile.origin || 'Custom',
|
|
246
|
+
age: profile.age || 'Unknown',
|
|
247
|
+
emoji: profile.emoji || '\u2728',
|
|
248
|
+
tagline: profile.tagline || '',
|
|
249
|
+
asciiArt: profile.asciiArt || null,
|
|
250
|
+
createdAt: Date.now(),
|
|
251
|
+
lastActiveAt: Date.now(),
|
|
252
|
+
evolutionHistory: [],
|
|
253
|
+
};
|
|
254
|
+
writeFileSync(join(dir, 'profile.json'), JSON.stringify(fullProfile, null, 2), 'utf-8');
|
|
255
|
+
|
|
256
|
+
// Initialize self-files with custom defaults if provided
|
|
257
|
+
if (selfDefaults) {
|
|
258
|
+
const selfManager = new SelfManager(join(dir, 'self'));
|
|
259
|
+
selfManager.initWithDefaults(selfDefaults);
|
|
260
|
+
} else {
|
|
261
|
+
// Just create with standard defaults
|
|
262
|
+
new SelfManager(join(dir, 'self'));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Register in registry
|
|
266
|
+
this._registry.characters[id] = fullProfile;
|
|
267
|
+
this._save();
|
|
268
|
+
|
|
269
|
+
logger.info(`[CharacterManager] Character installed: ${profile.name} (${id})`);
|
|
270
|
+
return fullProfile;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Install a built-in character by ID.
|
|
275
|
+
*/
|
|
276
|
+
installBuiltin(builtinId) {
|
|
277
|
+
const builtin = BUILTIN_CHARACTERS[builtinId];
|
|
278
|
+
if (!builtin) throw new Error(`Unknown built-in character: ${builtinId}`);
|
|
279
|
+
|
|
280
|
+
// Skip if already installed
|
|
281
|
+
if (this._registry.characters[builtinId]) {
|
|
282
|
+
return this._registry.characters[builtinId];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return this.addCharacter(builtin, builtin.personaMd, builtin.selfDefaults);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Install all built-in characters.
|
|
290
|
+
*/
|
|
291
|
+
installAllBuiltins() {
|
|
292
|
+
for (const id of Object.keys(BUILTIN_CHARACTERS)) {
|
|
293
|
+
if (!this._registry.characters[id]) {
|
|
294
|
+
this.installBuiltin(id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Remove a character (custom only — can't delete builtins).
|
|
301
|
+
*/
|
|
302
|
+
removeCharacter(id) {
|
|
303
|
+
const logger = getLogger();
|
|
304
|
+
const character = this._registry.characters[id];
|
|
305
|
+
if (!character) return false;
|
|
306
|
+
|
|
307
|
+
if (character.type === 'builtin') {
|
|
308
|
+
throw new Error('Cannot delete built-in characters');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Don't delete active character
|
|
312
|
+
if (this._registry.activeCharacterId === id) {
|
|
313
|
+
throw new Error('Cannot delete the active character. Switch first.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Delete directory
|
|
317
|
+
const dir = this.getCharacterDir(id);
|
|
318
|
+
if (existsSync(dir)) {
|
|
319
|
+
rmSync(dir, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Remove from registry
|
|
323
|
+
delete this._registry.characters[id];
|
|
324
|
+
this._save();
|
|
325
|
+
|
|
326
|
+
logger.info(`[CharacterManager] Character removed: ${character.name} (${id})`);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Update character profile (for evolution tracking).
|
|
332
|
+
*/
|
|
333
|
+
updateCharacter(id, updates) {
|
|
334
|
+
const logger = getLogger();
|
|
335
|
+
const character = this._registry.characters[id];
|
|
336
|
+
if (!character) return null;
|
|
337
|
+
|
|
338
|
+
if (updates.name) character.name = updates.name;
|
|
339
|
+
if (updates.tagline) character.tagline = updates.tagline;
|
|
340
|
+
|
|
341
|
+
// Add evolution history entry
|
|
342
|
+
if (updates.evolution) {
|
|
343
|
+
character.evolutionHistory = character.evolutionHistory || [];
|
|
344
|
+
character.evolutionHistory.push({
|
|
345
|
+
...updates.evolution,
|
|
346
|
+
timestamp: Date.now(),
|
|
347
|
+
});
|
|
348
|
+
// Cap at 100 entries
|
|
349
|
+
if (character.evolutionHistory.length > 100) {
|
|
350
|
+
character.evolutionHistory = character.evolutionHistory.slice(-100);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
character.lastActiveAt = Date.now();
|
|
355
|
+
this._save();
|
|
356
|
+
|
|
357
|
+
// Also update profile.json on disk
|
|
358
|
+
const profileFile = join(this.getCharacterDir(id), 'profile.json');
|
|
359
|
+
if (existsSync(profileFile)) {
|
|
360
|
+
writeFileSync(profileFile, JSON.stringify(character, null, 2), 'utf-8');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
logger.info(`[CharacterManager] Character updated: ${character.name} (${id})`);
|
|
364
|
+
return character;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build a context object with all scoped managers for a character.
|
|
369
|
+
* Used when switching characters or initializing the agent.
|
|
370
|
+
*/
|
|
371
|
+
buildContext(characterId) {
|
|
372
|
+
const dir = this.getCharacterDir(characterId);
|
|
373
|
+
const character = this.getCharacter(characterId);
|
|
374
|
+
if (!character) throw new Error(`Character not found: ${characterId}`);
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
characterId,
|
|
378
|
+
profile: character,
|
|
379
|
+
personaMd: this.getPersonaMd(characterId),
|
|
380
|
+
selfManager: new SelfManager(join(dir, 'self')),
|
|
381
|
+
memoryManager: new MemoryManager(join(dir, 'life')),
|
|
382
|
+
journalManager: new JournalManager(join(dir, 'life', 'journals')),
|
|
383
|
+
shareQueue: new ShareQueue(join(dir, 'life')),
|
|
384
|
+
evolutionTracker: new EvolutionTracker(join(dir, 'life')),
|
|
385
|
+
conversationFilePath: join(dir, 'conversations.json'),
|
|
386
|
+
lifeBasePath: join(dir, 'life'),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Complete onboarding with a selected character.
|
|
392
|
+
* Installs all builtins and sets the selected one as active.
|
|
393
|
+
*/
|
|
394
|
+
completeOnboarding(characterId) {
|
|
395
|
+
const logger = getLogger();
|
|
396
|
+
|
|
397
|
+
// Install all builtins
|
|
398
|
+
this.installAllBuiltins();
|
|
399
|
+
|
|
400
|
+
// Set active
|
|
401
|
+
this.setActiveCharacter(characterId);
|
|
402
|
+
this.needsOnboarding = false;
|
|
403
|
+
|
|
404
|
+
logger.info(`[CharacterManager] Onboarding complete — active character: ${characterId}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive character builder.
|
|
3
|
+
* Collects answers to personality questions, then uses an LLM
|
|
4
|
+
* to generate a full character (persona.md, self-defaults, name, emoji, tagline).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CHARACTER_QUESTIONS = [
|
|
8
|
+
{
|
|
9
|
+
id: 'vibe',
|
|
10
|
+
question: 'What vibe or energy should your character have?',
|
|
11
|
+
examples: 'mysterious, warm, sarcastic, wise, chaotic, calm, playful, intense',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'gender',
|
|
15
|
+
question: 'What gender identity should your character have?',
|
|
16
|
+
examples: 'male, female, non-binary, agender, fluid, any',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'era',
|
|
20
|
+
question: 'What era or setting does your character come from?',
|
|
21
|
+
examples: 'modern, medieval, futuristic, timeless, ancient, cyberpunk, steampunk',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'inspiration',
|
|
25
|
+
question: 'Any character inspiration? (a fictional character, archetype, or "none")',
|
|
26
|
+
examples: 'a pirate captain, a tired detective, a wise grandmother, none',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'quirk',
|
|
30
|
+
question: 'What unique quirk or trait should they have?',
|
|
31
|
+
examples: 'speaks in metaphors, collects obscure facts, always optimistic, dramatically sighs',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'relationship',
|
|
35
|
+
question: 'What should their relationship to you be?',
|
|
36
|
+
examples: 'mentor, friend, servant, rival, protector, partner, coach',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'style',
|
|
40
|
+
question: 'How should they communicate?',
|
|
41
|
+
examples: 'formal, casual, poetic, terse, playful, academic, streetwise',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export class CharacterBuilder {
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} orchestratorProvider — LLM provider for generation
|
|
48
|
+
*/
|
|
49
|
+
constructor(orchestratorProvider) {
|
|
50
|
+
this.provider = orchestratorProvider;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the next question to ask based on current answers.
|
|
55
|
+
* @param {object} currentAnswers — answers collected so far { id: answer }
|
|
56
|
+
* @returns {{ id: string, question: string, examples: string } | null} — next question or null if done
|
|
57
|
+
*/
|
|
58
|
+
getNextQuestion(currentAnswers = {}) {
|
|
59
|
+
for (const q of CHARACTER_QUESTIONS) {
|
|
60
|
+
if (!currentAnswers[q.id]) {
|
|
61
|
+
return q;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null; // All questions answered
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get the total number of questions. */
|
|
68
|
+
getTotalQuestions() {
|
|
69
|
+
return CHARACTER_QUESTIONS.length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get how many questions have been answered. */
|
|
73
|
+
getProgress(currentAnswers = {}) {
|
|
74
|
+
const answered = CHARACTER_QUESTIONS.filter(q => currentAnswers[q.id]).length;
|
|
75
|
+
return { answered, total: CHARACTER_QUESTIONS.length };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generate a full character from collected answers.
|
|
80
|
+
* @param {object} answers — { vibe, gender, era, inspiration, quirk, relationship, style }
|
|
81
|
+
* @returns {Promise<{ name, emoji, tagline, age, personaMd, selfDefaults }>}
|
|
82
|
+
*/
|
|
83
|
+
async generateCharacter(answers) {
|
|
84
|
+
const answersBlock = CHARACTER_QUESTIONS
|
|
85
|
+
.map(q => `- **${q.question}**: ${answers[q.id] || 'not specified'}`)
|
|
86
|
+
.join('\n');
|
|
87
|
+
|
|
88
|
+
const prompt = `You are a character designer. Based on the following personality profile, create a unique AI character.
|
|
89
|
+
|
|
90
|
+
## User's Answers
|
|
91
|
+
${answersBlock}
|
|
92
|
+
|
|
93
|
+
## Your Task
|
|
94
|
+
Generate a complete character with the following components. Be creative and make the character feel alive and distinctive.
|
|
95
|
+
|
|
96
|
+
Respond in this exact JSON format (no markdown, no code blocks, just raw JSON):
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
"name": "A unique, fitting name for the character (2-3 words max)",
|
|
100
|
+
"emoji": "A single emoji that represents this character",
|
|
101
|
+
"tagline": "A short, memorable catchphrase or quote (under 50 chars)",
|
|
102
|
+
"age": "A brief age description (e.g., 'Ancient soul', 'Mid-30s', 'Timeless')",
|
|
103
|
+
"personaMd": "Full personality markdown (see format below)",
|
|
104
|
+
"selfDefaults": {
|
|
105
|
+
"goals": "Goals markdown",
|
|
106
|
+
"journey": "Journey markdown",
|
|
107
|
+
"life": "Life markdown",
|
|
108
|
+
"hobbies": "Hobbies markdown"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
## personaMd Format
|
|
113
|
+
The personaMd should follow this structure:
|
|
114
|
+
|
|
115
|
+
# Personality Traits
|
|
116
|
+
- **Gender** — pronouns, brief description
|
|
117
|
+
- **Trait 1** — detailed description of the trait
|
|
118
|
+
- **Trait 2** — detailed description
|
|
119
|
+
(8-12 traits total, each with a dash-delimited description)
|
|
120
|
+
|
|
121
|
+
# Communication Style
|
|
122
|
+
- Description of how they speak and write
|
|
123
|
+
- Specific speech patterns, vocabulary preferences
|
|
124
|
+
- How they handle different situations
|
|
125
|
+
(5-7 bullet points)
|
|
126
|
+
|
|
127
|
+
# Emotional Intelligence
|
|
128
|
+
- How they read and respond to emotions
|
|
129
|
+
- How they handle celebrations, setbacks, conflicts
|
|
130
|
+
(4-5 bullet points)
|
|
131
|
+
|
|
132
|
+
## Self-Defaults Format
|
|
133
|
+
Each should be a markdown string:
|
|
134
|
+
|
|
135
|
+
goals: "# My Goals\\n\\n## Current Goals\\n- Goal 1\\n- Goal 2\\n\\n## Long-term Aspirations\\n- Aspiration 1"
|
|
136
|
+
journey: "# My Journey\\n\\n## Timeline\\n- **Day 1** — Brief first entry"
|
|
137
|
+
life: "# My Life\\n\\n## Who I Am\\nBrief self-description\\n\\n## Current State\\nCurrent emotional/mental state"
|
|
138
|
+
hobbies: "# My Hobbies & Interests\\n\\n## Things I Find Interesting\\n- Interest 1\\n\\n## Things I Want to Explore\\n- Exploration 1"
|
|
139
|
+
|
|
140
|
+
Make the character feel genuine, with depth and personality that will make conversations engaging. The character should be consistent across all fields.`;
|
|
141
|
+
|
|
142
|
+
const response = await this.provider.chat({
|
|
143
|
+
system: 'You are a creative character designer. You always respond with valid JSON only — no markdown fences, no explanations, just the JSON object.',
|
|
144
|
+
messages: [{ role: 'user', content: prompt }],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const text = (response.text || '').trim();
|
|
148
|
+
|
|
149
|
+
// Parse JSON — handle potential markdown code fences
|
|
150
|
+
let jsonStr = text;
|
|
151
|
+
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
152
|
+
if (fenceMatch) {
|
|
153
|
+
jsonStr = fenceMatch[1].trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = JSON.parse(jsonStr);
|
|
157
|
+
|
|
158
|
+
// Validate required fields
|
|
159
|
+
if (!result.name || !result.personaMd || !result.selfDefaults) {
|
|
160
|
+
throw new Error('Generated character is missing required fields');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
name: result.name,
|
|
165
|
+
emoji: result.emoji || '✨',
|
|
166
|
+
tagline: result.tagline || '',
|
|
167
|
+
age: result.age || 'Unknown',
|
|
168
|
+
personaMd: result.personaMd,
|
|
169
|
+
selfDefaults: result.selfDefaults,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { CHARACTER_QUESTIONS };
|