import { InMemoryMemoryStore } from "./memory"; import { addUtcDays, blocksToDailySchedule, blocksToMonthlySchedule, buildAvailabilitySnapshot, daysInMonth, scheduleInstruction, scheduleTargetDay, startOfUtcDay, toIso, } from "./schedule"; import { extractFact } from "identitydb"; import { buildMandatoryConversationContext, conversationInstruction, formatMessageHistory, } from "./conversation"; import type { BoxBrainMemoryStore, DateTimeInput, DebugEvent, FactDraft, MemorySpace, OutgoingMessageDraft, PersonaMessage, PersonaOptions, ScheduleEntry, ScheduledAvailabilitySnapshot, } from "./types"; interface CreateMode { type: "create"; displayName: string; seedMessage: string; } interface LoadMode { type: "load"; spaceId: string; } type Mode = CreateMode | LoadMode; function defaultInitialFact( displayName: string, seedMessage: string, ): FactDraft { return { statement: `${displayName} is a BoxBrain persona initialized from this seed: ${seedMessage}`, topics: ["persona", displayName], source: "boxbrain.persona.initialization", confidence: 1, metadata: { boxbrainType: "persona-initial-fact" }, }; } function ensureDraft(draft: OutgoingMessageDraft): OutgoingMessageDraft { return { messages: draft.messages.map((message) => message.trim()).filter(Boolean), ...(draft.reasoning === undefined ? {} : { reasoning: draft.reasoning }), }; } export class Persona { private readonly memory: BoxBrainMemoryStore; private readonly options: PersonaOptions; private readonly mode: Mode; private readonly readyPromise: Promise; private availabilitySnapshot?: ScheduledAvailabilitySnapshot; constructor( displayName: string, seedMessage: string, options?: PersonaOptions, ); constructor(spaceId: string, options?: PersonaOptions); constructor( first: string, second?: string | PersonaOptions, third?: PersonaOptions, ) { if (typeof second === "string") { this.mode = { type: "create", displayName: first, seedMessage: second }; this.options = third ?? {}; } else { this.mode = { type: "load", spaceId: first }; this.options = second ?? {}; } this.memory = this.options.memory ?? new InMemoryMemoryStore(); this.readyPromise = this.initialize(); } async ready(): Promise { return this.readyPromise; } get spaceId(): Promise { return this.readyPromise.then((v) => v.id); } async createDailySchedule( datetime: DateTimeInput, message: string, ): Promise { const persona = await this.ready(); if (!this.options.models?.schedule) { throw new Error("createDailySchedule requires options.models.schedule."); } const targetDay = scheduleTargetDay(datetime); const blocks = await this.options.models.schedule.generateDailySchedule({ persona, targetDay, message, instruction: scheduleInstruction(), }); const entries = blocksToDailySchedule({ persona, targetDay, message, blocks, }); await this.emit("persona.schedule.daily.generated", { targetDay: targetDay.toISOString(), count: entries.length, message, }); await this.memory.saveScheduleEntries(persona.id, entries); await this.refreshAvailability(datetime); return entries; } async createMonthlySchedule( datetime: DateTimeInput, message: string, ): Promise { const persona = await this.ready(); if (!this.options.models?.schedule) { throw new Error( "createMonthlySchedule requires options.models.schedule.", ); } const fromDay = scheduleTargetDay(datetime); const days = daysInMonth(fromDay); const blocks = await this.options.models.schedule.generateMonthlySchedule({ persona, fromDay, message, days, instruction: scheduleInstruction(), }); const entries = blocksToMonthlySchedule({ persona, fromDay, message, blocks, }); await this.emit("persona.schedule.monthly.generated", { count: entries.length, message, }); await this.memory.saveScheduleEntries(persona.id, entries); await this.refreshAvailability(datetime); return entries; } async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise { const persona = await this.ready(); const cutoff = toIso(cutoffExclusive); const deleted = await this.memory.deleteScheduleEntriesBefore( persona.id, cutoff, ); await this.memory.addFact(persona.id, { statement: `Schedules before ${cutoff} were deleted or marked inactive.`, topics: ["persona.schedule.deleted", "schedule", cutoff.slice(0, 10)], source: "boxbrain.schedule.prune", metadata: { boxbrainType: "schedule-deletion", cutoffExclusive: cutoff, deleted, }, }); await this.emit("persona.schedule.deleted", { cutoffExclusive: cutoff, deleted, }); return deleted; } async deleteSchedulesOlderThan(datetime: DateTimeInput): Promise { return this.deleteSchedulesBefore(datetime); } async getTodayScheduledAvailability( datetime: DateTimeInput, ): Promise { if (!this.availabilitySnapshot) { await this.refreshAvailability(datetime); } const snapshot = this.availabilitySnapshot; if (!snapshot) throw new Error("Availability snapshot was not initialized."); const today = startOfUtcDay(datetime).toISOString(); if (snapshot.windowStartAt !== today) { await this.refreshAvailability(datetime); } const refreshed = this.availabilitySnapshot; if (!refreshed) throw new Error("Availability snapshot was not initialized."); return refreshed; } async sendMessage(input: { datetime: DateTimeInput; messageHistory: PersonaMessage[]; getLatestMessageHistory?: () => Promise; }): Promise { const persona = await this.ready(); if (!this.options.models?.conversation) { throw new Error("sendMessage requires options.models.conversation."); } const availability = await this.getTodayScheduledAvailability( input.datetime, ); const context = await buildMandatoryConversationContext({ persona, now: input.datetime, memory: this.memory, messages: input.messageHistory, availability, }); await this.emit("persona.conversation.context.loaded", { factCount: context.personaAndUserFacts.length, scheduleEntryCount: context.scheduleEntries.length, }); const userMessage = [...input.messageHistory] .reverse() .find((message) => message.sender === "user")?.content; let draft = ensureDraft( await this.options.models.conversation.generateReply({ persona, now: toIso(input.datetime), mode: "reply", context, ...(userMessage === undefined ? {} : { userMessage }), instruction: conversationInstruction(), }), ); if (input.getLatestMessageHistory && this.options.models.rewrite) { const latest = await input.getLatestMessageHistory(); if (latest.length > input.messageHistory.length) { const latestContext = await buildMandatoryConversationContext({ persona, now: input.datetime, memory: this.memory, messages: latest, availability, }); const decision = await this.options.models.rewrite.decide({ persona, now: toIso(input.datetime), previousHistory: input.messageHistory, latestHistory: latest, draft, context: latestContext, }); await this.emit("persona.conversation.rewrite.checked", { rewrite: decision.rewrite, reason: decision.reason ?? null, }); if (decision.rewrite) { draft = ensureDraft( decision.draft ?? (await this.options.models.conversation.generateReply({ persona, now: toIso(input.datetime), mode: "reply", context: latestContext, instruction: conversationInstruction(), })), ); } } } await this.emit("persona.conversation.reply.generated", { messageCount: draft.messages.length, }); return draft; } async startConversation(input: { datetime: DateTimeInput; messageHistory: PersonaMessage[]; }): Promise { const persona = await this.ready(); if (!this.options.models?.conversation) { throw new Error( "startConversation requires options.models.conversation.", ); } const availability = await this.getTodayScheduledAvailability( input.datetime, ); const context = await buildMandatoryConversationContext({ persona, now: input.datetime, memory: this.memory, messages: input.messageHistory, availability, }); const draft = ensureDraft( await this.options.models.conversation.generateReply({ persona, now: toIso(input.datetime), mode: "start-conversation", context, instruction: conversationInstruction(), }), ); await this.emit("persona.conversation.started", { messageCount: draft.messages.length, }); return draft; } async sleepMemory(input: { datetime: DateTimeInput; messageHistory: PersonaMessage[]; }): Promise { const persona = await this.ready(); if (!this.options.models?.factExtractor) { throw new Error("sleepMemory requires options.models.factExtractor."); } const contextFacts = await this.memory.findFacts(persona.id, [ "persona", persona.displayName, "user", ]); const statement = [ `Current objective time: ${toIso(input.datetime)}.`, "Read the message history and extract durable facts worth remembering.", "Objectivize subjective statements before storage.", 'Example: "I started TypeScript in 2025" becomes "The user started TypeScript in 2025."', "Prefer facts about the persona, the user, their relationship, preferences, history, schedule-relevant events, and stable traits.", "", "Context facts:", ...contextFacts.map((f) => `- ${f.statement}`), "", "Message history:", formatMessageHistory({ personaName: persona.displayName, messages: input.messageHistory, }), ].join("\n"); const extracted = await extractFact( statement, this.options.models.factExtractor, ); const draft: FactDraft = { statement: extracted.statement ?? statement, topics: [...extracted.topics.map((t) => t.name), "sleepMemory"], source: extracted.source ?? "boxbrain.sleepMemory", ...(typeof extracted.confidence === 'number' ? { confidence: extracted.confidence } : {}), ...(extracted.metadata !== undefined && extracted.metadata !== null ? { metadata: extracted.metadata as Record } : {}), }; await this.memory.addFact(persona.id, draft); await this.emit("persona.memory.sleep.persisted", { factCount: 1, }); return [draft]; } private async initialize(): Promise { const now = toIso(this.options.now ?? new Date()); if (this.mode.type === "load") { const existing = await this.memory.getSpace(this.mode.spaceId); if (!existing) throw new Error(`Persona space not found: ${this.mode.spaceId}`); await this.emit("persona.loaded", { displayName: existing.displayName }); await this.refreshAvailability(now, existing); return existing; } const space = await this.memory.createSpace({ displayName: this.mode.displayName, seedMessage: this.mode.seedMessage, now, }); if (this.options.models?.factExtractor) { const statement = `Persona: ${this.mode.displayName}\nSeed: ${this.mode.seedMessage}`; const extracted = await extractFact( statement, this.options.models.factExtractor, ); const draft: FactDraft = { statement: extracted.statement ?? statement, topics: extracted.topics.map((t) => t.name), source: extracted.source ?? "boxbrain.persona.initialization", ...(typeof extracted.confidence === 'number' ? { confidence: extracted.confidence } : {}), ...(extracted.metadata !== undefined && extracted.metadata !== null ? { metadata: extracted.metadata as Record } : {}), }; await this.memory.addFact(space.id, draft); await this.emit( "persona.initialized", { displayName: space.displayName, factCount: 1 }, space.id, ); } else { const fact = defaultInitialFact(this.mode.displayName, this.mode.seedMessage); await this.memory.addFact(space.id, fact); await this.emit( "persona.initialized", { displayName: space.displayName, factCount: 1 }, space.id, ); } await this.refreshAvailability(now, space); return space; } private async refreshAvailability( datetime: DateTimeInput, knownPersona?: MemorySpace, ): Promise { const persona = knownPersona ?? (await this.ready()); const start = startOfUtcDay(datetime); const end = addUtcDays(start, 2); const entries = await this.memory.listScheduleEntries( persona.id, start.toISOString(), end.toISOString(), ); this.availabilitySnapshot = buildAvailabilitySnapshot({ now: datetime, entries, }); await this.emit( "persona.availability.refreshed", { rangeCount: this.availabilitySnapshot.ranges.length }, persona.id, ); } private async emit( name: string, data?: Record, explicitSpaceId?: string, ): Promise { if (!this.options.debug) return; const event: DebugEvent = { name, time: new Date().toISOString(), ...(explicitSpaceId === undefined ? {} : { spaceId: explicitSpaceId }), ...(data === undefined ? {} : { data }), }; await this.options.debug(event); } }