feat: add persona initialization

This commit is contained in:
2026-05-11 16:10:09 +09:00
parent 7b474ddac3
commit c5a3d7e835
6 changed files with 582 additions and 8 deletions

View File

@@ -2,13 +2,21 @@
BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that can behave like human-like DM contacts.
The project is framework-first rather than product-first. It provides typed services and adapter contracts for:
The project is framework-first rather than product-first. The current foundation provides:
- initializing a persona from personality, history, values, preferences, and relationships
- storing one persona per IdentityDB memory space
- generating and persisting schedule and availability facts
- running DM-style conversations with short multi-message replies and human-like typing delays
- choosing AI text, structured-output, and image providers through simple adapters
- provider-agnostic text, structured-output, and image adapter contracts
- one IdentityDB memory space per persona
- persona initialization from personality, history, values, preferences, and relationships
- LLM-generated biography ingestion into IdentityDB fact drafts
- optional profile image generation through an image adapter
- human-like typing and first-reply delay utilities
Planned next APIs include:
- schedule generation and availability state persistence
- inbound DM-style conversation turns with mandatory/contextual memory retrieval
- proactive outbound messages without user input
- HTTP/RPC wrappers around the core library APIs
## Development

View File

@@ -1,4 +1,5 @@
export * from './adapters';
export * from './memory';
export * from './persona';
export * from './timing';
export * from './types';

View File

@@ -26,11 +26,12 @@ export async function persistFactDrafts(db: IdentityDB, input: PersistFactDrafts
}
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {
const source = draft.source ?? input.source;
const factInput: AddFactInput = {
spaceName: input.spaceName,
statement: draft.statement,
source: draft.source ?? input.source,
metadata: withBoxBrainMetadata(draft.metadata, input.domain, input.source),
source,
metadata: withBoxBrainMetadata(draft.metadata, input.domain, source),
topics: draft.topics.map(toTopicInput),
};

277
src/persona.ts Normal file
View File

@@ -0,0 +1,277 @@
import { randomUUID } from 'node:crypto';
import type { IdentityDB } from 'identitydb';
import type { ImageModelAdapter, StructuredModelAdapter } from './adapters';
import { persistFactDrafts } from './memory';
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from './types';
export interface PersonaRelationshipInput {
name: string;
relationship: string;
description?: string | undefined;
}
export interface InitializePersonaInput {
id?: string | undefined;
displayName: string;
spaceName?: string | undefined;
personality: string;
history?: string | undefined;
values?: string[] | undefined;
likes?: string[] | undefined;
dislikes?: string[] | undefined;
relationships?: PersonaRelationshipInput[] | undefined;
currentDate?: string | undefined;
structuredModel: StructuredModelAdapter;
imageModel?: ImageModelAdapter | undefined;
generateProfileImage?: boolean | undefined;
reuseExistingSpace?: boolean | undefined;
}
export interface PersonaBiographyResult {
biography: string;
}
export interface PersonaFactExtractionResult {
facts: BoxBrainFactDraft[];
}
export interface InitializedPersona extends BoxBrainPersonaProfile {
biography: string;
}
const PERSONA_BIOGRAPHY_SCHEMA = {
type: 'object',
required: ['biography'],
properties: {
biography: { type: 'string', minLength: 1 },
},
} as const;
const PERSONA_FACT_EXTRACTION_SCHEMA = {
type: 'object',
required: ['facts'],
properties: {
facts: {
type: 'array',
items: {
type: 'object',
required: ['statement', 'topics'],
properties: {
statement: { type: 'string', minLength: 1 },
topics: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } },
},
},
},
},
},
},
} as const;
export async function initializePersona(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
const id = input.id ?? createPersonaId(input.displayName);
const spaceName = input.spaceName ?? `persona:${id}`;
const existingSpace = await db.getSpaceByName(spaceName);
if (existingSpace && !input.reuseExistingSpace) {
throw new Error(`Persona space already exists: ${spaceName}. Pass reuseExistingSpace to append to it intentionally.`);
}
if (input.generateProfileImage && !input.imageModel) {
throw new Error('generateProfileImage requires an imageModel adapter.');
}
const biography = assertPersonaBiographyResult(
await input.structuredModel.generateObject<PersonaBiographyResult>({
prompt: buildBiographyPrompt(input),
schema: PERSONA_BIOGRAPHY_SCHEMA,
metadata: {
boxbrainTask: 'persona.biography.generate',
personaId: id,
},
}),
);
const extracted = assertPersonaFactExtractionResult(
await input.structuredModel.generateObject<PersonaFactExtractionResult>({
prompt: buildFactExtractionPrompt(input.displayName, biography.biography),
schema: PERSONA_FACT_EXTRACTION_SCHEMA,
metadata: {
boxbrainTask: 'persona.biography.extract_facts',
personaId: id,
},
}),
);
let profileImageUrl: string | undefined;
if (input.generateProfileImage && input.imageModel) {
const image = await input.imageModel.generateImage({
prompt: buildProfileImagePrompt(input.displayName, biography.biography),
aspectRatio: 'square',
metadata: {
boxbrainTask: 'persona.profile_image.generate',
personaId: id,
},
});
profileImageUrl = image.url ?? image.path;
}
await db.upsertSpace({
name: spaceName,
description: `BoxBrain persona memory space for ${input.displayName}`,
metadata: {
boxbrain: {
domain: 'persona.space',
personaId: id,
displayName: input.displayName,
},
},
});
await persistFactDrafts(db, {
spaceName,
domain: 'persona.biography',
source: `${input.structuredModel.provider}:${input.structuredModel.model}`,
facts: extracted.facts,
});
if (profileImageUrl && input.imageModel) {
await persistFactDrafts(db, {
spaceName,
domain: 'persona.profile_image',
source: `${input.imageModel.provider}:${input.imageModel.model}`,
facts: [
{
statement: `${input.displayName} has a generated profile image at ${profileImageUrl}.`,
topics: [
{ name: input.displayName, category: 'entity' },
{ name: 'profile image', category: 'concept' },
],
metadata: { profileImageUrl },
},
],
});
}
const persona: InitializedPersona = {
id,
spaceName,
displayName: input.displayName,
biography: biography.biography,
};
if (profileImageUrl !== undefined) {
persona.profileImageUrl = profileImageUrl;
}
return persona;
}
function buildBiographyPrompt(input: InitializePersonaInput): string {
return [
'Create a concrete, detailed life biography for a synthetic persona from birth to now.',
'The biography must include personality, history, values, preferences, dislikes, and relationships when provided.',
`Current date: ${input.currentDate ?? new Date().toISOString().slice(0, 10)}`,
`Display name: ${input.displayName}`,
`Personality: ${input.personality}`,
optionalLine('History', input.history),
listLine('Values', input.values),
listLine('Likes', input.likes),
listLine('Dislikes', input.dislikes),
relationshipLine(input.relationships),
'Return structured output with a biography string.',
]
.filter((line): line is string => Boolean(line))
.join('\n');
}
function buildFactExtractionPrompt(displayName: string, biography: string): string {
return [
`Split ${displayName}'s biography into concrete IdentityDB-ready facts.`,
'Every fact must have a statement and at least one topic.',
'Prefer specific topics for people, places, values, preferences, relationships, and time periods.',
`Biography:\n${biography}`,
'Return structured output with a facts array.',
].join('\n');
}
function buildProfileImagePrompt(displayName: string, biography: string): string {
return [
`Create a natural profile image for ${displayName}.`,
'The image should look like a believable DM profile picture, not a studio product shot.',
`Persona biography summary: ${biography}`,
].join('\n');
}
function createPersonaId(displayName: string): string {
const normalized = displayName
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, '-')
.replace(/^-+|-+$/g, '');
const prefix = normalized || 'persona';
return `${prefix}-${randomUUID()}`;
}
function assertPersonaBiographyResult(value: PersonaBiographyResult): PersonaBiographyResult {
if (!value || typeof value.biography !== 'string' || value.biography.trim().length === 0) {
throw new Error('Structured persona biography output must include a non-empty biography string.');
}
return value;
}
function assertPersonaFactExtractionResult(value: PersonaFactExtractionResult): PersonaFactExtractionResult {
if (!value || !Array.isArray(value.facts)) {
throw new Error('Structured persona fact extraction output must include a facts array.');
}
for (let factIndex = 0; factIndex < value.facts.length; factIndex += 1) {
const fact = value.facts[factIndex]!;
if (!fact || typeof fact.statement !== 'string' || fact.statement.trim().length === 0) {
throw new Error(`Structured persona fact extraction output has an invalid statement at index ${factIndex}.`);
}
if (!Array.isArray(fact.topics) || fact.topics.length === 0) {
throw new Error(`Structured persona fact extraction output has no topics at index ${factIndex}.`);
}
for (let topicIndex = 0; topicIndex < fact.topics.length; topicIndex += 1) {
const topic = fact.topics[topicIndex]!;
if (!topic || typeof topic.name !== 'string' || topic.name.trim().length === 0) {
throw new Error(
`Structured persona fact extraction output has an invalid topic name at fact index ${factIndex}, topic index ${topicIndex}.`,
);
}
}
}
return value;
}
function optionalLine(label: string, value: string | undefined): string | undefined {
return value ? `${label}: ${value}` : undefined;
}
function listLine(label: string, values: string[] | undefined): string | undefined {
return values && values.length > 0 ? `${label}: ${values.join(', ')}` : undefined;
}
function relationshipLine(relationships: PersonaRelationshipInput[] | undefined): string | undefined {
if (!relationships || relationships.length === 0) {
return undefined;
}
return `Relationships: ${relationships
.map((relationship) => {
const details = relationship.description ? ` (${relationship.description})` : '';
return `${relationship.name}${relationship.relationship}${details}`;
})
.join('; ')}`;
}

View File

@@ -57,4 +57,28 @@ describe('persistFactDrafts', () => {
expect(facts).toEqual([]);
expect(await db.getSpaceByName('persona:empty')).toBeNull();
});
it('keeps fact source metadata consistent when a draft overrides source', async () => {
const db = await createDb();
const [fact] = await persistFactDrafts(db, {
spaceName: 'persona:source-test',
domain: 'persona.biography',
source: 'boxbrain:batch',
facts: [
{
statement: 'Minji wrote a diary entry.',
source: 'boxbrain:draft',
topics: [{ name: 'Minji' }, { name: 'diary entry' }],
},
],
});
expect(fact?.source).toBe('boxbrain:draft');
expect(fact?.metadata).toMatchObject({
boxbrain: {
source: 'boxbrain:draft',
},
});
});
});

263
tests/persona.test.ts Normal file
View File

@@ -0,0 +1,263 @@
import { afterEach, describe, expect, it } from 'vitest';
import { IdentityDB } from 'identitydb';
import { initializePersona, type PersonaBiographyResult, type PersonaFactExtractionResult } from '../src';
import type { ImageModelAdapter, StructuredModelAdapter } from '../src';
const openDbs: IdentityDB[] = [];
async function createDb() {
const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
await db.initialize();
openDbs.push(db);
return db;
}
afterEach(async () => {
while (openDbs.length > 0) {
await openDbs.pop()!.close();
}
});
describe('initializePersona', () => {
it('generates a biography, extracts facts, and stores them in the persona space', async () => {
const db = await createDb();
const prompts: string[] = [];
const structured = createStructuredAdapter(prompts);
const persona = await initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet, playful, observant.',
history: 'Grew up near Busan and studied design.',
values: ['loyalty', 'creative honesty'],
likes: ['quiet cafés'],
dislikes: ['loud arguments'],
relationships: [{ name: 'Haru', relationship: 'best friend', description: 'childhood friend' }],
currentDate: '2026-05-11',
structuredModel: structured,
});
expect(persona).toMatchObject({
id: 'minji',
displayName: 'Minji',
spaceName: 'persona:minji',
biography: 'Minji grew up near the sea and values loyal friendships.',
});
expect(prompts[0]).toContain('Quiet, playful, observant.');
expect(prompts[0]).toContain('creative honesty');
expect(prompts[0]).toContain('Haru');
expect(prompts[0]).toContain('Current date: 2026-05-11');
expect(prompts[1]).toContain('Minji grew up near the sea');
const facts = await db.getTopicFacts('Minji', { spaceName: persona.spaceName });
expect(facts.map((fact) => fact.statement)).toContain('Minji grew up near the sea.');
});
it('does not call the image adapter unless a profile image is requested', async () => {
const db = await createDb();
const structured = createStructuredAdapter([]);
let imageCalls = 0;
const imageModel: ImageModelAdapter = {
provider: 'fake-image',
model: 'fake-image-model',
async generateImage() {
imageCalls += 1;
return { url: 'https://example.test/minji.png' };
},
};
const persona = await initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: structured,
imageModel,
});
expect(persona.profileImageUrl).toBeUndefined();
expect(imageCalls).toBe(0);
});
it('can generate and store a persona profile image when requested', async () => {
const db = await createDb();
const structured = createStructuredAdapter([]);
let imagePrompt = '';
const imageModel: ImageModelAdapter = {
provider: 'fake-image',
model: 'fake-image-model',
async generateImage(request) {
imagePrompt = request.prompt;
return { url: 'https://example.test/minji.png' };
},
};
const persona = await initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: structured,
imageModel,
generateProfileImage: true,
});
expect(imagePrompt).toContain('Minji');
expect(persona.profileImageUrl).toBe('https://example.test/minji.png');
const facts = await db.getTopicFacts('profile image', { spaceName: persona.spaceName });
expect(facts.map((fact) => fact.statement)).toContain('Minji has a generated profile image at https://example.test/minji.png.');
});
it('generates unique default persona spaces when no id is supplied', async () => {
const db = await createDb();
const first = await initializePersona(db, {
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: createStructuredAdapter([]),
});
const second = await initializePersona(db, {
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: createStructuredAdapter([]),
});
expect(first.id).not.toBe(second.id);
expect(first.spaceName).not.toBe(second.spaceName);
});
it('rejects accidental reuse of an existing persona space by default', async () => {
const db = await createDb();
await initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: createStructuredAdapter([]),
});
await expect(
initializePersona(db, {
id: 'minji',
displayName: 'Minji Again',
personality: 'Different.',
structuredModel: createStructuredAdapter([]),
}),
).rejects.toThrow(/already exists/);
});
it('allows explicit reuse of an existing persona space', async () => {
const db = await createDb();
await initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: createStructuredAdapter([]),
});
const persona = await initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: createStructuredAdapter([]),
reuseExistingSpace: true,
});
expect(persona.spaceName).toBe('persona:minji');
});
it('rejects malformed structured biography output', async () => {
const db = await createDb();
const malformed: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'fake-structured-model',
async generateObject<TOutput>(): Promise<TOutput> {
return { biography: '' } as TOutput;
},
};
await expect(
initializePersona(db, {
id: 'broken',
displayName: 'Broken',
personality: 'Quiet.',
structuredModel: malformed,
}),
).rejects.toThrow(/biography/);
expect(await db.getSpaceByName('persona:broken')).toBeNull();
});
it('rejects malformed extracted fact topics before creating a space', async () => {
const db = await createDb();
const malformedFacts: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'fake-structured-model',
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
if (request.prompt.includes('Create a concrete')) {
return { biography: 'Broken has a biography.' } as TOutput;
}
return {
facts: [
{
statement: 'Broken has a malformed topic.',
topics: [{}],
},
],
} as TOutput;
},
};
await expect(
initializePersona(db, {
id: 'broken-topic',
displayName: 'Broken Topic',
personality: 'Quiet.',
structuredModel: malformedFacts,
}),
).rejects.toThrow(/topic name/);
expect(await db.getSpaceByName('persona:broken-topic')).toBeNull();
});
it('requires an image model when profile image generation is requested', async () => {
const db = await createDb();
await expect(
initializePersona(db, {
id: 'minji',
displayName: 'Minji',
personality: 'Quiet.',
structuredModel: createStructuredAdapter([]),
generateProfileImage: true,
}),
).rejects.toThrow(/imageModel/);
});
});
function createStructuredAdapter(prompts: string[]): StructuredModelAdapter {
let call = 0;
return {
provider: 'fake-structured',
model: 'fake-structured-model',
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
prompts.push(request.prompt);
call += 1;
if (call === 1) {
return {
biography: 'Minji grew up near the sea and values loyal friendships.',
} satisfies PersonaBiographyResult as TOutput;
}
return {
facts: [
{
statement: 'Minji grew up near the sea.',
topics: [{ name: 'Minji' }, { name: 'sea' }],
},
{
statement: 'Minji values loyal friendships.',
topics: [{ name: 'Minji' }, { name: 'loyal friendships' }],
},
],
} satisfies PersonaFactExtractionResult as TOutput;
},
};
}