diff --git a/src/conversation.ts b/src/conversation.ts index 21fa5b0..c6aef33 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -5,36 +5,39 @@ import type { MemorySpace, PersonaMessage, ScheduledAvailabilitySnapshot, -} from './types'; -import { addUtcDays, buildAvailabilitySnapshot, dateKeysAround, startOfUtcDay, toIso } from './schedule'; +} from "./types"; +import { addUtcDays, dateKeysAround, startOfUtcDay, toIso } from "./schedule"; -export function formatMessageHistory(input: { personaName: string; messages: PersonaMessage[] }): string { +export function formatMessageHistory(input: { + personaName: string; + messages: PersonaMessage[]; +}): string { return input.messages .map((message) => { - const sender = message.sender === 'persona' ? input.personaName : 'user'; + const sender = message.sender === "persona" ? input.personaName : "user"; return `${sender}@${toIso(message.time)}: ${message.content}`; }) - .join('\n'); + .join("\n"); } export function conversationInstruction(): string { return [ - 'You are controlling the persona, not a generic assistant.', - 'Use the send_message tool conceptually: return one or more outgoing messages.', - 'Unless the persona strongly prefers otherwise, keep each outgoing message to at most one sentence.', - 'Prefer short, natural, chat-like wording and allow splitting one thought into multiple messages.', + "You are controlling the persona, not a generic assistant.", + "Use the send_message tool conceptually: return one or more outgoing messages.", + "Unless the persona strongly prefers otherwise, keep each outgoing message to at most one sentence.", + "Prefer short, natural, chat-like wording and allow splitting one thought into multiple messages.", 'If mandatory memory says "기억이 없음", the persona may naturally wonder about missing context instead of pretending to remember.', - ].join('\n'); + ].join("\n"); } export function memoryExtractionInstruction(now: string): string { return [ `Current objective time: ${now}.`, - 'Read the message history and extract durable facts worth remembering.', - 'Objectivize subjective statements before storage.', + "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.', - ].join('\n'); + "Prefer facts about the persona, the user, their relationship, preferences, history, schedule-relevant events, and stable traits.", + ].join("\n"); } export async function buildMandatoryConversationContext(input: { @@ -47,15 +50,27 @@ export async function buildMandatoryConversationContext(input: { const now = startOfUtcDay(input.now); const from = addUtcDays(now, -1).toISOString(); const to = addUtcDays(now, 2).toISOString(); - const scheduleEntries = await input.memory.listScheduleEntries(input.persona.id, from, to); - const personaAndUserFacts = await input.memory.findFacts(input.persona.id, ['persona', input.persona.displayName, 'user']); - const memorySummary = personaAndUserFacts.length === 0 - ? '기억이 없음' - : personaAndUserFacts.map((fact) => `- ${fact.statement}`).join('\n'); + const scheduleEntries = await input.memory.listScheduleEntries( + input.persona.id, + from, + to, + ); + const personaAndUserFacts = await input.memory.findFacts(input.persona.id, [ + "persona", + input.persona.displayName, + "user", + ]); + const memorySummary = + personaAndUserFacts.length === 0 + ? "기억이 없음" + : personaAndUserFacts.map((fact) => `- ${fact.statement}`).join("\n"); return { - formattedMessageHistory: formatMessageHistory({ personaName: input.persona.displayName, messages: input.messages }), - conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(', ')}.`, + formattedMessageHistory: formatMessageHistory({ + personaName: input.persona.displayName, + messages: input.messages, + }), + conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(", ")}.`, memorySummary, personaAndUserFacts, scheduleEntries, diff --git a/src/persona.ts b/src/persona.ts index 5cbd237..ce4e754 100644 --- a/src/persona.ts +++ b/src/persona.ts @@ -1,4 +1,4 @@ -import { InMemoryMemoryStore } from './memory'; +import { InMemoryMemoryStore } from "./memory"; import { addUtcDays, blocksToDailySchedule, @@ -9,13 +9,13 @@ import { scheduleTargetDay, startOfUtcDay, toIso, -} from './schedule'; +} from "./schedule"; import { buildMandatoryConversationContext, conversationInstruction, formatMessageHistory, memoryExtractionInstruction, -} from './conversation'; +} from "./conversation"; import type { BoxBrainMemoryStore, DateTimeInput, @@ -27,28 +27,31 @@ import type { PersonaOptions, ScheduleEntry, ScheduledAvailabilitySnapshot, -} from './types'; +} from "./types"; interface CreateMode { - type: 'create'; + type: "create"; displayName: string; seedMessage: string; } interface LoadMode { - type: 'load'; + type: "load"; spaceId: string; } type Mode = CreateMode | LoadMode; -function defaultInitialFact(displayName: string, seedMessage: string): FactDraft { +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', + topics: ["persona", displayName], + source: "boxbrain.persona.initialization", confidence: 1, - metadata: { boxbrainType: 'persona-initial-fact' }, + metadata: { boxbrainType: "persona-initial-fact" }, }; } @@ -66,14 +69,22 @@ export class Persona { private readonly readyPromise: Promise; private availabilitySnapshot?: ScheduledAvailabilitySnapshot; - constructor(displayName: string, seedMessage: string, options?: PersonaOptions); + 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 }; + 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.mode = { type: "load", spaceId: first }; this.options = second ?? {}; } this.memory = this.options.memory ?? new InMemoryMemoryStore(); @@ -84,10 +95,17 @@ export class Persona { return this.readyPromise; } - async createDailySchedule(datetime: DateTimeInput, message: string): Promise { + 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.'); + throw new Error("createDailySchedule requires options.models.schedule."); } const targetDay = scheduleTargetDay(datetime); const blocks = await this.options.models.schedule.generateDailySchedule({ @@ -96,17 +114,31 @@ export class Persona { message, instruction: scheduleInstruction(), }); - const entries = blocksToDailySchedule({ persona, targetDay, message, blocks }); - await this.emit('persona.schedule.daily.generated', { targetDay: targetDay.toISOString(), count: entries.length, message }); + 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 { + 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.'); + throw new Error( + "createMonthlySchedule requires options.models.schedule.", + ); } const fromDay = scheduleTargetDay(datetime); const days = daysInMonth(fromDay); @@ -117,8 +149,16 @@ export class Persona { days, instruction: scheduleInstruction(), }); - const entries = blocksToMonthlySchedule({ persona, fromDay, message, blocks }); - await this.emit('persona.schedule.monthly.generated', { count: entries.length, message }); + 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; @@ -127,14 +167,24 @@ export class Persona { async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise { const persona = await this.ready(); const cutoff = toIso(cutoffExclusive); - const deleted = await this.memory.deleteScheduleEntriesBefore(persona.id, cutoff); + 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 }, + 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, }); - await this.emit('persona.schedule.deleted', { cutoffExclusive: cutoff, deleted }); return deleted; } @@ -142,12 +192,15 @@ export class Persona { return this.deleteSchedulesBefore(datetime); } - async getTodayScheduledAvailability(datetime: DateTimeInput): Promise { + 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.'); + if (!snapshot) + throw new Error("Availability snapshot was not initialized."); const today = startOfUtcDay(datetime).toISOString(); if (snapshot.windowStartAt !== today) { @@ -155,7 +208,8 @@ export class Persona { } const refreshed = this.availabilitySnapshot; - if (!refreshed) throw new Error('Availability snapshot was not initialized.'); + if (!refreshed) + throw new Error("Availability snapshot was not initialized."); return refreshed; } @@ -166,9 +220,11 @@ export class Persona { }): Promise { const persona = await this.ready(); if (!this.options.models?.conversation) { - throw new Error('sendMessage requires options.models.conversation.'); + throw new Error("sendMessage requires options.models.conversation."); } - const availability = await this.getTodayScheduledAvailability(input.datetime); + const availability = await this.getTodayScheduledAvailability( + input.datetime, + ); const context = await buildMandatoryConversationContext({ persona, now: input.datetime, @@ -176,20 +232,24 @@ export class Persona { messages: input.messageHistory, availability, }); - await this.emit('persona.conversation.context.loaded', { + 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(), - })); + 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(); @@ -209,20 +269,28 @@ export class Persona { draft, context: latestContext, }); - await this.emit('persona.conversation.rewrite.checked', { rewrite: decision.rewrite, reason: decision.reason ?? null }); + 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(), - })); + 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 }); + await this.emit("persona.conversation.reply.generated", { + messageCount: draft.messages.length, + }); return draft; } @@ -232,9 +300,13 @@ export class Persona { }): Promise { const persona = await this.ready(); if (!this.options.models?.conversation) { - throw new Error('startConversation requires options.models.conversation.'); + throw new Error( + "startConversation requires options.models.conversation.", + ); } - const availability = await this.getTodayScheduledAvailability(input.datetime); + const availability = await this.getTodayScheduledAvailability( + input.datetime, + ); const context = await buildMandatoryConversationContext({ persona, now: input.datetime, @@ -242,14 +314,18 @@ export class Persona { 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 }); + 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; } @@ -259,64 +335,102 @@ export class Persona { }): Promise { const persona = await this.ready(); if (!this.options.models?.memoryExtraction) { - throw new Error('sleepMemory requires options.models.memoryExtraction.'); + throw new Error("sleepMemory requires options.models.memoryExtraction."); } - const contextFacts = await this.memory.findFacts(persona.id, ['persona', persona.displayName, 'user']); + const contextFacts = await this.memory.findFacts(persona.id, [ + "persona", + persona.displayName, + "user", + ]); const drafts = await this.options.models.memoryExtraction.extract({ persona, now: toIso(input.datetime), - formattedMessageHistory: formatMessageHistory({ personaName: persona.displayName, messages: input.messageHistory }), + formattedMessageHistory: formatMessageHistory({ + personaName: persona.displayName, + messages: input.messageHistory, + }), contextFacts, instruction: memoryExtractionInstruction(toIso(input.datetime)), }); for (const draft of drafts) { await this.memory.addFact(persona.id, { ...draft, - topics: [...draft.topics, 'sleepMemory'], - source: draft.source ?? 'boxbrain.sleepMemory', + topics: [...draft.topics, "sleepMemory"], + source: draft.source ?? "boxbrain.sleepMemory", }); } - await this.emit('persona.memory.sleep.persisted', { factCount: drafts.length }); + await this.emit("persona.memory.sleep.persisted", { + factCount: drafts.length, + }); return drafts; } private async initialize(): Promise { const now = toIso(this.options.now ?? new Date()); - if (this.mode.type === 'load') { + 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 }); + 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 }); + const space = await this.memory.createSpace({ + displayName: this.mode.displayName, + seedMessage: this.mode.seedMessage, + now, + }); const modelFacts = this.options.models?.initialization ? await this.options.models.initialization.extractInitialFacts({ - displayName: this.mode.displayName, - seedMessage: this.mode.seedMessage, - now, - }) + displayName: this.mode.displayName, + seedMessage: this.mode.seedMessage, + now, + }) : undefined; - const facts = modelFacts ?? [defaultInitialFact(this.mode.displayName, this.mode.seedMessage)]; + const facts = modelFacts ?? [ + defaultInitialFact(this.mode.displayName, this.mode.seedMessage), + ]; for (const fact of facts) { await this.memory.addFact(space.id, fact); } - await this.emit('persona.initialized', { displayName: space.displayName, factCount: facts.length }, space.id); + await this.emit( + "persona.initialized", + { displayName: space.displayName, factCount: facts.length }, + space.id, + ); await this.refreshAvailability(now, space); return space; } - private async refreshAvailability(datetime: DateTimeInput, knownPersona?: MemorySpace): Promise { - const persona = knownPersona ?? await this.ready(); + 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); + 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 { + private async emit( + name: string, + data?: Record, + explicitSpaceId?: string, + ): Promise { if (!this.options.debug) return; const event: DebugEvent = { name,