From c5a3d7e835257f12ec16386dbccca10600a8a905 Mon Sep 17 00:00:00 2001 From: Shinwoo PARK Date: Mon, 11 May 2026 16:10:09 +0900 Subject: [PATCH] feat: add persona initialization --- README.md | 20 ++- src/index.ts | 1 + src/memory.ts | 5 +- src/persona.ts | 277 ++++++++++++++++++++++++++++++++++++++++++ tests/memory.test.ts | 24 ++++ tests/persona.test.ts | 263 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 582 insertions(+), 8 deletions(-) create mode 100644 src/persona.ts create mode 100644 tests/persona.test.ts diff --git a/README.md b/README.md index dcea35a..c5d1e04 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/index.ts b/src/index.ts index 40351e3..9a5a478 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './adapters'; export * from './memory'; +export * from './persona'; export * from './timing'; export * from './types'; diff --git a/src/memory.ts b/src/memory.ts index a767665..4d109f3 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -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), }; diff --git a/src/persona.ts b/src/persona.ts new file mode 100644 index 0000000..8651e9d --- /dev/null +++ b/src/persona.ts @@ -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 { + 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({ + prompt: buildBiographyPrompt(input), + schema: PERSONA_BIOGRAPHY_SCHEMA, + metadata: { + boxbrainTask: 'persona.biography.generate', + personaId: id, + }, + }), + ); + + const extracted = assertPersonaFactExtractionResult( + await input.structuredModel.generateObject({ + 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('; ')}`; +} diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 3fa4a32..5f8017b 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -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', + }, + }); + }); }); diff --git a/tests/persona.test.ts b/tests/persona.test.ts new file mode 100644 index 0000000..9f9224f --- /dev/null +++ b/tests/persona.test.ts @@ -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(): Promise { + 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(request: { prompt: string }): Promise { + 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(request: { prompt: string }): Promise { + 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; + }, + }; +}