From 3ee6b233ea8af28013a9e850cf5041f3492743b3 Mon Sep 17 00:00:00 2001 From: Shinwoo PARK Date: Mon, 11 May 2026 17:01:19 +0900 Subject: [PATCH] feat: add BoxBrain persona runtime APIs --- README.md | 24 +- src/adapters.ts | 17 ++ src/availability.ts | 272 +++++++++++++++++++ src/conversation.ts | 543 +++++++++++++++++++++++++++++++++++++ src/facts.ts | 95 +++++++ src/index.ts | 3 + src/schedule.ts | 424 +++++++++++++++++++++++++++++ src/types.ts | 64 +++++ tests/availability.test.ts | 143 ++++++++++ tests/conversation.test.ts | 258 ++++++++++++++++++ tests/helpers.ts | 16 ++ tests/public-api.test.ts | 15 + tests/schedule.test.ts | 178 ++++++++++++ 13 files changed, 2043 insertions(+), 9 deletions(-) create mode 100644 src/availability.ts create mode 100644 src/conversation.ts create mode 100644 src/facts.ts create mode 100644 src/schedule.ts create mode 100644 tests/availability.test.ts create mode 100644 tests/conversation.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/schedule.test.ts diff --git a/README.md b/README.md index c5d1e04..ffc28dc 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ # BoxBrain -BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that can behave like human-like DM contacts. +BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that behave like human-like DM contacts. -The project is framework-first rather than product-first. The current foundation provides: +The project is framework-first rather than product-first. The current core library provides: -- provider-agnostic text, structured-output, and image adapter contracts +- provider-agnostic text, structured-output, image, conversation-memory, and special-date 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 +- schedule generation for day/week/month scopes with optional external special-date context +- schedule persistence, listing, and pruning APIs +- availability state persistence with schedule/manual/tool overrides +- availability snapshots with current + next transition calculation +- DM-style conversation orchestration for inbound replies and proactive openings +- delegated mandatory/contextual memory retrieval pipelines for conversation turns +- human-like first-reply delay and typing delay utilities +- farewell-style refusal flows that can trigger availability-changing tool calls -Planned next APIs include: +Still planned: -- 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 +- ready-made provider adapter packages for specific AI vendors +- production-focused persistence/runtime integrations beyond the in-process core library ## Development @@ -29,6 +35,6 @@ bun run build ## Current status -The repository is in foundation development. See the implementation plan: +The repository now contains the framework core for persona initialization, schedule/status management, and conversation orchestration. See the implementation plan: - `docs/plans/2026-05-11-boxbrain-foundation.md` diff --git a/src/adapters.ts b/src/adapters.ts index d9aaeba..ecff406 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -1,4 +1,5 @@ import type { JsonValue } from 'identitydb'; +import type { BoxBrainScheduleScope } from './types'; export interface TextGenerationRequest { prompt: string; @@ -42,3 +43,19 @@ export interface ImageModelAdapter { model: string; generateImage(request: ImageGenerationRequest): Promise; } + +export interface SpecialDateContext { + date: string; + title: string; + description?: string | undefined; +} + +export interface SpecialDateRequest { + anchorDate: string; + scope: BoxBrainScheduleScope; + timezone?: string | undefined; +} + +export interface SpecialDateProvider { + listSpecialDates(request: SpecialDateRequest): Promise; +} diff --git a/src/availability.ts b/src/availability.ts new file mode 100644 index 0000000..adbb7f7 --- /dev/null +++ b/src/availability.ts @@ -0,0 +1,272 @@ +import { randomUUID } from 'node:crypto'; +import type { Fact, IdentityDB, JsonValue } from 'identitydb'; +import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from './facts'; +import { persistFactDrafts } from './memory'; +import type { + BoxBrainAvailabilityEntry, + BoxBrainAvailabilityMode, + BoxBrainAvailabilitySnapshot, + BoxBrainAvailabilitySourceType, +} from './types'; + +export interface SetAvailabilityStatusInput { + spaceName: string; + mode: BoxBrainAvailabilityMode; + reason?: string | undefined; + effectiveFrom: string; + until?: string | undefined; + sourceType?: Exclude | undefined; + eventId?: string | undefined; + metadata?: JsonValue | null | undefined; +} + +export interface GetAvailabilitySnapshotInput { + spaceName: string; + at: string; +} + +export interface ListAvailabilityEntriesInput { + spaceName: string; +} + +const EXPLICIT_SOURCE_PRIORITY: Record, number> = { + schedule: 1, + manual: 2, + tool: 3, +}; + +export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise { + assertAvailabilityMode(input.mode); + assertChronology(input.effectiveFrom, input.until); + + const statusId = randomUUID(); + const sourceType = input.sourceType ?? 'manual'; + const [fact] = await persistFactDrafts(db, { + spaceName: input.spaceName, + domain: 'persona.availability', + source: `boxbrain:availability.${sourceType}`, + facts: [ + { + statement: buildAvailabilityStatement(input.mode, input.reason, input.effectiveFrom, input.until), + metadata: jsonObject({ + availabilityStatus: jsonObject({ + id: statusId, + mode: input.mode, + reason: input.reason, + effectiveFrom: input.effectiveFrom, + until: input.until, + sourceType, + eventId: input.eventId, + customMetadata: input.metadata ?? null, + }), + }), + topics: [ + { name: dateOnly(input.effectiveFrom), category: 'temporal' }, + { name: input.mode, category: 'concept' }, + { name: 'availability', category: 'concept' }, + ], + }, + ], + }); + + const parsed = fact ? parseAvailabilityFact(fact) : null; + if (!parsed) { + throw new Error('Failed to persist BoxBrain availability status.'); + } + + return parsed; +} + +export async function listAvailabilityEntries( + db: IdentityDB, + input: ListAvailabilityEntriesInput, +): Promise { + const facts = await listFactsInSpace(db, input.spaceName); + const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts); + + return facts + .map(parseAvailabilityFact) + .filter((entry): entry is BoxBrainAvailabilityEntry => entry !== null) + .filter((entry) => entry.sourceType !== 'schedule' || !entry.eventId || !deletedScheduleEventIds.has(entry.eventId)) + .sort(compareAvailabilityEntries); +} + +export async function getAvailabilitySnapshot( + db: IdentityDB, + input: GetAvailabilitySnapshotInput, +): Promise { + const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName }); + const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at); + const next = selectNextAvailabilityTransition(entries, input.at, current); + + return { current, next }; +} + +function selectAvailabilityAt(entries: BoxBrainAvailabilityEntry[], at: string): BoxBrainAvailabilityEntry | null { + const atMs = Date.parse(at); + const applicable = entries.filter((entry) => { + const startMs = Date.parse(entry.effectiveFrom); + const endMs = entry.until ? Date.parse(entry.until) : Number.POSITIVE_INFINITY; + return Number.isFinite(startMs) && atMs >= startMs && atMs < endMs; + }); + + if (applicable.length === 0) { + return null; + } + + applicable.sort((left, right) => { + const priority = priorityOf(right.sourceType) - priorityOf(left.sourceType); + if (priority !== 0) { + return priority; + } + if (right.effectiveFrom !== left.effectiveFrom) { + return right.effectiveFrom.localeCompare(left.effectiveFrom); + } + return (right.createdAt ?? '').localeCompare(left.createdAt ?? ''); + }); + + return applicable[0] ?? null; +} + +function selectNextAvailabilityTransition( + entries: BoxBrainAvailabilityEntry[], + at: string, + current: BoxBrainAvailabilityEntry, +): BoxBrainAvailabilityEntry | null { + const atMs = Date.parse(at); + const transitionTimes = Array.from(new Set( + entries.flatMap((entry) => [Date.parse(entry.effectiveFrom), entry.until ? Date.parse(entry.until) : Number.NaN]), + )) + .filter((time): time is number => Number.isFinite(time) && time > atMs) + .sort((left, right) => left - right); + + for (const transitionTime of transitionTimes) { + const transitionAt = new Date(transitionTime).toISOString(); + const probeAt = new Date(transitionTime + 1).toISOString(); + const candidate = selectAvailabilityAt(entries, probeAt) ?? createDefaultOnlineAvailability(transitionAt); + if (!sameAvailabilityState(candidate, current)) { + return { + ...candidate, + effectiveFrom: transitionAt, + }; + } + } + + return null; +} + +function createDefaultOnlineAvailability(at: string): BoxBrainAvailabilityEntry { + return { + id: 'default-online', + mode: 'online', + effectiveFrom: at, + sourceType: 'default', + }; +} + +function parseAvailabilityFact(fact: Fact): BoxBrainAvailabilityEntry | null { + if (getFactDomain(fact) !== 'persona.availability') { + return null; + } + + const metadata = getJsonObject(fact.metadata); + const payload = getJsonObject(metadata?.availabilityStatus); + if (!payload) { + return null; + } + + const id = typeof payload.id === 'string' ? payload.id : fact.id; + const mode = payload.mode; + const effectiveFrom = payload.effectiveFrom; + const sourceType = payload.sourceType; + if (typeof mode !== 'string' || typeof effectiveFrom !== 'string' || typeof sourceType !== 'string') { + return null; + } + assertAvailabilityMode(mode); + if (!isExplicitSourceType(sourceType)) { + return null; + } + + return { + id, + mode, + reason: typeof payload.reason === 'string' ? payload.reason : undefined, + effectiveFrom, + until: typeof payload.until === 'string' ? payload.until : undefined, + sourceType, + eventId: typeof payload.eventId === 'string' ? payload.eventId : undefined, + createdAt: fact.createdAt, + metadata: payload.customMetadata ?? null, + }; +} + +function compareAvailabilityEntries(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): number { + if (left.effectiveFrom !== right.effectiveFrom) { + return left.effectiveFrom.localeCompare(right.effectiveFrom); + } + return (left.createdAt ?? '').localeCompare(right.createdAt ?? ''); +} + +function buildAvailabilityStatement( + mode: BoxBrainAvailabilityMode, + reason: string | undefined, + effectiveFrom: string, + until: string | undefined, +): string { + const reasonPart = reason ? ` because ${reason}` : ''; + const untilPart = until ? ` until ${until}` : ''; + return `Availability is ${mode} from ${effectiveFrom}${untilPart}${reasonPart}.`; +} + +function collectDeletedScheduleEventIds(facts: Fact[]): Set { + return new Set( + facts + .filter((fact) => getFactDomain(fact) === 'persona.schedule.deleted') + .map((fact) => getJsonObject(getJsonObject(fact.metadata)?.scheduleDeletion)?.eventId) + .filter((eventId): eventId is string => typeof eventId === 'string'), + ); +} + +function sameAvailabilityState(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): boolean { + return left.mode === right.mode + && left.reason === right.reason + && left.until === right.until + && left.sourceType === right.sourceType + && left.eventId === right.eventId; +} + +function assertValidTimestamp(value: string, fieldName: string): void { + if (!Number.isFinite(Date.parse(value))) { + throw new Error(`Availability ${fieldName} must be a valid ISO timestamp.`); + } +} + +function assertChronology(startAt: string, endAt: string | undefined): void { + assertValidTimestamp(startAt, 'effectiveFrom'); + if (!endAt) { + return; + } + + assertValidTimestamp(endAt, 'until'); + if (Date.parse(endAt) <= Date.parse(startAt)) { + throw new Error('Availability until must be later than effectiveFrom.'); + } +} + +function assertAvailabilityMode(mode: string): asserts mode is BoxBrainAvailabilityMode { + if (mode !== 'online' && mode !== 'do_not_disturb' && mode !== 'offline') { + throw new Error(`Unsupported availability mode: ${mode}`); + } +} + +function isExplicitSourceType(sourceType: string): sourceType is Exclude { + return sourceType === 'schedule' || sourceType === 'manual' || sourceType === 'tool'; +} + +function priorityOf(sourceType: BoxBrainAvailabilitySourceType): number { + if (sourceType === 'default') { + return 0; + } + + return EXPLICIT_SOURCE_PRIORITY[sourceType]; +} diff --git a/src/conversation.ts b/src/conversation.ts new file mode 100644 index 0000000..825f263 --- /dev/null +++ b/src/conversation.ts @@ -0,0 +1,543 @@ +import { randomUUID } from 'node:crypto'; +import type { Fact, IdentityDB, JsonValue } from 'identitydb'; +import type { StructuredModelAdapter } from './adapters'; +import { getAvailabilitySnapshot, setAvailabilityStatus } from './availability'; +import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from './facts'; +import { persistFactDrafts } from './memory'; +import { createReplyDelay, createTypingDelay } from './timing'; +import type { + BoxBrainAvailabilityMode, + BoxBrainConversationDirection, + BoxBrainConversationEntry, + BoxBrainMemoryReference, + BoxBrainMessage, + BoxBrainToolCall, +} from './types'; + +export interface ConversationMemorySelectionResult { + memoryIds: string[]; +} + +export interface SetAvailabilityToolArguments extends Record { + mode: BoxBrainAvailabilityMode; + reason?: string; + effectiveFrom?: string; + until?: string; +} + +export interface ConversationToolCall extends BoxBrainToolCall { + name: 'setAvailabilityStatus'; +} + +export interface ConversationTurnPlan { + mode: 'reply' | 'refuse'; + messages: string[]; + toolCalls?: ConversationToolCall[] | undefined; +} + +interface BaseConversationInput { + spaceName: string; + counterpartId: string; + counterpartDisplayName?: string | undefined; + currentTime: string; + mandatoryMemoryModel: StructuredModelAdapter; + contextualMemoryModel: StructuredModelAdapter; + responseModel: StructuredModelAdapter; + rng?: (() => number) | undefined; + activeExchangeWindowSeconds?: number | undefined; + isFirstReplyInExchange?: boolean | undefined; +} + +export interface ReplyToConversationInput extends BaseConversationInput { + message: string; +} + +export interface StartConversationInput extends BaseConversationInput {} + +export interface ConversationTurnResult { + blocked: boolean; + blockedReason?: string | undefined; + blockedUntil?: string | undefined; + messages: BoxBrainMessage[]; + usedMemories: BoxBrainMemoryReference[]; + toolCallsExecuted: ConversationToolCall[]; +} + +export interface ListConversationEntriesInput { + spaceName: string; + counterpartId?: string | undefined; + since?: string | undefined; + until?: string | undefined; +} + +export async function replyToConversation(db: IdentityDB, input: ReplyToConversationInput): Promise { + const turnId = randomUUID(); + await persistConversationEntry(db, { + spaceName: input.spaceName, + counterpartId: input.counterpartId, + counterpartDisplayName: input.counterpartDisplayName, + direction: 'inbound', + text: input.message, + occurredAt: input.currentTime, + proactive: false, + turnId, + source: 'boxbrain:conversation.inbound', + }); + + return generateConversationTurn(db, { + ...input, + proactive: false, + turnId, + inboundMessage: input.message, + }); +} + +export async function startConversation(db: IdentityDB, input: StartConversationInput): Promise { + return generateConversationTurn(db, { + ...input, + proactive: true, + turnId: randomUUID(), + }); +} + +export async function listConversationEntries( + db: IdentityDB, + input: ListConversationEntriesInput, +): Promise { + const facts = await listFactsInSpace(db, input.spaceName); + return facts + .map(parseConversationFact) + .filter((entry): entry is BoxBrainConversationEntry => entry !== null) + .filter((entry) => !input.counterpartId || entry.counterpartId === input.counterpartId) + .filter((entry) => !input.since || entry.occurredAt >= input.since) + .filter((entry) => !input.until || entry.occurredAt <= input.until) + .sort((left, right) => { + if (left.occurredAt !== right.occurredAt) { + return left.occurredAt.localeCompare(right.occurredAt); + } + if ((left.createdAt ?? '') !== (right.createdAt ?? '')) { + return (left.createdAt ?? '').localeCompare(right.createdAt ?? ''); + } + return left.id.localeCompare(right.id); + }); +} + +async function generateConversationTurn( + db: IdentityDB, + input: BaseConversationInput & { + proactive: boolean; + turnId: string; + inboundMessage?: string | undefined; + }, +): Promise { + const availability = await getAvailabilitySnapshot(db, { + spaceName: input.spaceName, + at: input.currentTime, + }); + if (availability.current.mode === 'offline') { + return { + blocked: true, + blockedReason: availability.current.reason, + blockedUntil: availability.current.until, + messages: [], + usedMemories: [], + toolCallsExecuted: [], + }; + } + + const persona = await resolvePersonaProfile(db, input.spaceName); + const candidateMemories = await buildMemoryCandidates(db, input.spaceName, input.counterpartId, input.counterpartDisplayName, input.currentTime); + const candidateMap = new Map( + candidateMemories.map((memory, index) => [`m${index + 1}`, memory]), + ); + const mandatorySelection = assertConversationMemorySelectionResult( + await input.mandatoryMemoryModel.generateObject({ + prompt: buildMandatoryMemoryPrompt(persona.displayName, input, candidateMap), + schema: { type: 'object', required: ['memoryIds'] }, + metadata: { + boxbrainTask: 'persona.conversation.select_mandatory_memories', + counterpartId: input.counterpartId, + }, + }), + ); + const contextualSelection = assertConversationMemorySelectionResult( + await input.contextualMemoryModel.generateObject({ + prompt: buildContextualMemoryPrompt(persona.displayName, input, candidateMap), + schema: { type: 'object', required: ['memoryIds'] }, + metadata: { + boxbrainTask: 'persona.conversation.select_contextual_memories', + counterpartId: input.counterpartId, + }, + }), + ); + + const usedMemories = uniqueMemoryIds([...mandatorySelection.memoryIds, ...contextualSelection.memoryIds]) + .map((id) => candidateMap.get(id)) + .filter((memory): memory is BoxBrainMemoryReference => Boolean(memory)); + + const plan = assertConversationTurnPlan( + await input.responseModel.generateObject({ + prompt: buildConversationPrompt(persona.displayName, input, availability, usedMemories), + schema: { type: 'object', required: ['mode', 'messages'] }, + metadata: { + boxbrainTask: input.proactive ? 'persona.conversation.initiate' : 'persona.conversation.reply', + counterpartId: input.counterpartId, + }, + }), + ); + + const historyBeforeResponse = await listConversationEntries(db, { + spaceName: input.spaceName, + counterpartId: input.counterpartId, + }); + const isFirstReply = input.isFirstReplyInExchange ?? inferIsFirstReply(historyBeforeResponse, input.currentTime, input.activeExchangeWindowSeconds ?? 300); + const replyDelaySeconds = createReplyDelay(availability.current, { + isFirstReplyInExchange: isFirstReply, + rng: input.rng, + }); + + if (replyDelaySeconds === null) { + return { + blocked: true, + blockedReason: availability.current.reason, + blockedUntil: availability.current.until, + messages: [], + usedMemories, + toolCallsExecuted: [], + }; + } + + const outboundMessages = plan.messages.map((text, index) => { + const typingDelaySeconds = createTypingDelay(text, { rng: input.rng }); + const message: BoxBrainMessage = { + text, + typingDelaySeconds, + replyDelaySeconds: index === 0 ? replyDelaySeconds : 0, + totalDelaySeconds: typingDelaySeconds + (index === 0 ? replyDelaySeconds : 0), + }; + return message; + }); + + for (const message of outboundMessages) { + await persistConversationEntry(db, { + spaceName: input.spaceName, + counterpartId: input.counterpartId, + counterpartDisplayName: input.counterpartDisplayName, + direction: 'outbound', + text: message.text, + occurredAt: input.currentTime, + proactive: input.proactive, + turnId: input.turnId, + source: `${input.responseModel.provider}:${input.responseModel.model}`, + }); + } + + const toolCallsExecuted = await executeToolCalls(db, input.spaceName, input.currentTime, plan.toolCalls ?? []); + + return { + blocked: false, + messages: outboundMessages, + usedMemories, + toolCallsExecuted, + }; +} + +async function buildMemoryCandidates( + db: IdentityDB, + spaceName: string, + counterpartId: string, + counterpartDisplayName: string | undefined, + currentTime: string, +): Promise { + const facts = await listFactsInSpace(db, spaceName); + const counterpartName = counterpartDisplayName ?? counterpartId; + const currentDate = dateOnly(currentTime); + const relevantDates = new Set([shiftIsoDate(currentDate, -1), currentDate, shiftIsoDate(currentDate, 1)]); + const conversationWindowMs = 1000 * 60 * 60 * 48; + const nowMs = Date.parse(currentTime); + + const scheduleRefs: BoxBrainMemoryReference[] = []; + const recentConversationRefs: BoxBrainMemoryReference[] = []; + const counterpartRefs: BoxBrainMemoryReference[] = []; + const personaRefs: BoxBrainMemoryReference[] = []; + + for (const fact of facts) { + const domain = getFactDomain(fact); + if (!domain) { + continue; + } + + if (domain === 'persona.schedule') { + const scheduleEvent = getJsonObject(getJsonObject(fact.metadata)?.scheduleEvent); + const startAt = typeof scheduleEvent?.startAt === 'string' ? scheduleEvent.startAt : undefined; + if (startAt && relevantDates.has(dateOnly(startAt))) { + scheduleRefs.push(toMemoryReference(fact, domain, `Schedule memory: ${fact.statement}`, startAt)); + } + continue; + } + + if (domain === 'persona.conversation') { + const entry = parseConversationFact(fact); + if (entry && entry.counterpartId === counterpartId && nowMs - Date.parse(entry.occurredAt) <= conversationWindowMs) { + recentConversationRefs.push(toMemoryReference(fact, domain, `Recent conversation: ${entry.direction} — ${entry.text}`, entry.occurredAt)); + } + continue; + } + + if (domain === 'persona.biography' || domain === 'persona.relationship' || domain === 'persona.profile_image') { + const relevantToCounterpart = fact.statement.includes(counterpartName) + || fact.topics.some((topic) => topic.name === counterpartName || topic.name === counterpartId); + const reference = toMemoryReference(fact, domain, fact.statement, fact.createdAt); + if (relevantToCounterpart) { + counterpartRefs.push(reference); + } else { + personaRefs.push(reference); + } + } + } + + return dedupeMemoryReferences([ + ...scheduleRefs, + ...recentConversationRefs, + ...counterpartRefs, + ...personaRefs, + ]).slice(0, 40); +} + +function buildMandatoryMemoryPrompt( + displayName: string, + input: BaseConversationInput & { inboundMessage?: string | undefined; proactive: boolean }, + candidateMap: Map, +): string { + return [ + `Select mandatory memories for ${displayName}'s DM turn.`, + 'You are the first retrieval model.', + 'Always prioritize yesterday, today, and tomorrow schedules when relevant, yesterday and today conversation context, and stable memories about the persona and counterpart.', + `Current time: ${input.currentTime}`, + `Counterpart: ${input.counterpartDisplayName ?? input.counterpartId}`, + input.inboundMessage ? `Inbound message: ${input.inboundMessage}` : 'This is a proactive outbound turn with no inbound user text.', + 'Return only memoryIds from the candidate list.', + 'Candidate memories:', + renderCandidateMemories(candidateMap), + ].join('\n'); +} + +function buildContextualMemoryPrompt( + displayName: string, + input: BaseConversationInput & { inboundMessage?: string | undefined; proactive: boolean }, + candidateMap: Map, +): string { + return [ + `Select additional contextual memories for ${displayName}'s DM turn.`, + 'You are the second retrieval model and should only add memories that help the reply feel natural.', + `Current time: ${input.currentTime}`, + input.inboundMessage ? `User message to analyze: ${input.inboundMessage}` : 'No inbound text; choose memories useful for starting a conversation first.', + 'Return only memoryIds from the candidate list.', + 'Candidate memories:', + renderCandidateMemories(candidateMap), + ].join('\n'); +} + +function buildConversationPrompt( + displayName: string, + input: BaseConversationInput & { inboundMessage?: string | undefined; proactive: boolean }, + availability: Awaited>, + memories: BoxBrainMemoryReference[], +): string { + return [ + `You are writing DM-style messages as ${displayName}.`, + 'Send 1 or more very short messages. Each message should be one sentence or less.', + 'You may intentionally omit spaces or make tiny typos to feel human.', + 'If needed, you may refuse because you need to go soon, and you may use the setAvailabilityStatus tool afterwards.', + `Current time: ${input.currentTime}`, + `Current availability: ${availability.current.mode}${availability.current.reason ? ` — ${availability.current.reason}` : ''}`, + availability.current.until ? `Current availability until: ${availability.current.until}` : undefined, + availability.next ? `next availability transition: ${availability.next.mode} at ${availability.next.effectiveFrom}${availability.next.reason ? ` — ${availability.next.reason}` : ''}` : 'next availability transition: none', + `Counterpart: ${input.counterpartDisplayName ?? input.counterpartId}`, + input.inboundMessage ? `Inbound message: ${input.inboundMessage}` : 'Goal: start the conversation first without waiting for user text.', + 'Selected memories:', + memories.length > 0 ? memories.map((memory) => `- ${memory.summary}`).join('\n') : '- none selected', + 'Available tool: setAvailabilityStatus({ mode, reason?, effectiveFrom?, until? })', + 'Return { mode, messages, toolCalls? }.', + ] + .filter((line): line is string => Boolean(line)) + .join('\n'); +} + +function renderCandidateMemories(candidateMap: Map): string { + return Array.from(candidateMap.entries()) + .map(([id, memory]) => `${id}: [${memory.domain}] ${memory.summary}`) + .join('\n'); +} + +function assertConversationMemorySelectionResult(value: ConversationMemorySelectionResult): ConversationMemorySelectionResult { + if (!value || !Array.isArray(value.memoryIds) || value.memoryIds.some((id) => typeof id !== 'string')) { + throw new Error('Conversation memory selection output must contain a memoryIds string array.'); + } + + return value; +} + +function assertConversationTurnPlan(value: ConversationTurnPlan): ConversationTurnPlan { + if (!value || (value.mode !== 'reply' && value.mode !== 'refuse')) { + throw new Error('Conversation turn plan must include a mode of reply or refuse.'); + } + if (!Array.isArray(value.messages) || value.messages.length === 0) { + throw new Error('Conversation turn plan must include at least one message.'); + } + for (const message of value.messages) { + if (typeof message !== 'string' || message.trim().length === 0 || message.includes('\n')) { + throw new Error('Conversation turn plan messages must be non-empty single-line strings.'); + } + } + if (value.toolCalls && !Array.isArray(value.toolCalls)) { + throw new Error('Conversation turn plan toolCalls must be an array when provided.'); + } + + return value; +} + +async function persistConversationEntry( + db: IdentityDB, + input: { + spaceName: string; + counterpartId: string; + counterpartDisplayName?: string | undefined; + direction: BoxBrainConversationDirection; + text: string; + occurredAt: string; + proactive: boolean; + turnId: string; + source: string; + }, +): Promise { + await persistFactDrafts(db, { + spaceName: input.spaceName, + domain: 'persona.conversation', + source: input.source, + facts: [ + { + statement: input.text, + metadata: jsonObject({ + conversationEntry: jsonObject({ + id: randomUUID(), + turnId: input.turnId, + direction: input.direction, + occurredAt: input.occurredAt, + counterpartId: input.counterpartId, + counterpartDisplayName: input.counterpartDisplayName, + proactive: input.proactive, + }), + }), + topics: [ + { name: input.counterpartDisplayName ?? input.counterpartId, category: 'entity' }, + { name: dateOnly(input.occurredAt), category: 'temporal' }, + { name: 'conversation', category: 'concept' }, + ], + }, + ], + }); +} + +function parseConversationFact(fact: Fact): BoxBrainConversationEntry | null { + if (getFactDomain(fact) !== 'persona.conversation') { + return null; + } + + const metadata = getJsonObject(fact.metadata); + const payload = getJsonObject(metadata?.conversationEntry); + if (!payload) { + return null; + } + + if ( + typeof payload.id !== 'string' + || typeof payload.turnId !== 'string' + || typeof payload.direction !== 'string' + || typeof payload.occurredAt !== 'string' + || typeof payload.counterpartId !== 'string' + || typeof payload.proactive !== 'boolean' + ) { + return null; + } + + return { + id: payload.id, + turnId: payload.turnId, + direction: payload.direction as BoxBrainConversationDirection, + text: fact.statement, + occurredAt: payload.occurredAt, + createdAt: fact.createdAt, + counterpartId: payload.counterpartId, + counterpartDisplayName: typeof payload.counterpartDisplayName === 'string' ? payload.counterpartDisplayName : undefined, + proactive: payload.proactive, + metadata: fact.metadata, + }; +} + +function toMemoryReference( + fact: Fact, + domain: BoxBrainMemoryReference['domain'], + summary: string, + occurredAt: string, +): BoxBrainMemoryReference { + return { + id: fact.id, + domain, + statement: fact.statement, + summary, + occurredAt, + metadata: fact.metadata, + }; +} + +function dedupeMemoryReferences(memories: BoxBrainMemoryReference[]): BoxBrainMemoryReference[] { + const byId = new Map(); + for (const memory of memories) { + byId.set(memory.id, memory); + } + return Array.from(byId.values()); +} + +function uniqueMemoryIds(ids: string[]): string[] { + return Array.from(new Set(ids.filter((id) => id.trim().length > 0))); +} + +function inferIsFirstReply(history: BoxBrainConversationEntry[], currentTime: string, activeWindowSeconds: number): boolean { + const latestOutbound = history + .slice() + .reverse() + .find((entry) => entry.direction === 'outbound'); + if (!latestOutbound) { + return true; + } + + return Date.parse(currentTime) - Date.parse(latestOutbound.occurredAt) > activeWindowSeconds * 1000; +} + +async function executeToolCalls( + db: IdentityDB, + spaceName: string, + currentTime: string, + toolCalls: ConversationToolCall[], +): Promise { + const executed: ConversationToolCall[] = []; + + for (const toolCall of toolCalls) { + if (toolCall.name !== 'setAvailabilityStatus') { + throw new Error(`Unsupported BoxBrain conversation tool: ${toolCall.name}`); + } + + await setAvailabilityStatus(db, { + spaceName, + mode: toolCall.arguments.mode, + reason: typeof toolCall.arguments.reason === 'string' ? toolCall.arguments.reason : undefined, + effectiveFrom: typeof toolCall.arguments.effectiveFrom === 'string' ? toolCall.arguments.effectiveFrom : currentTime, + until: typeof toolCall.arguments.until === 'string' ? toolCall.arguments.until : undefined, + sourceType: 'tool', + }); + executed.push(toolCall); + } + + return executed; +} diff --git a/src/facts.ts b/src/facts.ts new file mode 100644 index 0000000..081a861 --- /dev/null +++ b/src/facts.ts @@ -0,0 +1,95 @@ +import type { Fact, IdentityDB, JsonValue, Space } from 'identitydb'; +import type { BoxBrainFactDomain, BoxBrainPersonaProfile } from './types'; + +export async function listFactsInSpace(db: IdentityDB, spaceName: string): Promise { + const topics = await db.listTopics({ includeFacts: true, spaceName }); + const byId = new Map(); + + for (const topic of topics) { + for (const fact of topic.facts) { + byId.set(fact.id, fact); + } + } + + return Array.from(byId.values()).sort((left, right) => { + if (left.createdAt !== right.createdAt) { + return left.createdAt.localeCompare(right.createdAt); + } + return left.id.localeCompare(right.id); + }); +} + +export function getFactDomain(fact: Fact): BoxBrainFactDomain | null { + const metadata = getJsonObject(fact.metadata); + const boxbrain = getJsonObject(metadata?.boxbrain); + const domain = boxbrain?.domain; + return typeof domain === 'string' ? (domain as BoxBrainFactDomain) : null; +} + +export function getJsonObject(value: JsonValue | null | undefined): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +export function dateOnly(isoLike: string): string { + return isoLike.slice(0, 10); +} + +export function shiftIsoDate(date: string, days: number): string { + const parsed = new Date(`${date}T00:00:00.000Z`); + parsed.setUTCDate(parsed.getUTCDate() + days); + return parsed.toISOString().slice(0, 10); +} + +export async function resolvePersonaProfile( + db: IdentityDB, + spaceName: string, + fallbackDisplayName?: string, +): Promise { + const space = await db.getSpaceByName(spaceName); + return personaProfileFromSpace(space, spaceName, fallbackDisplayName); +} + +export function personaProfileFromSpace( + space: Space | null, + spaceName: string, + fallbackDisplayName?: string, +): BoxBrainPersonaProfile { + const metadata = getJsonObject(space?.metadata); + const boxbrain = getJsonObject(metadata?.boxbrain); + const id = typeof boxbrain?.personaId === 'string' + ? boxbrain.personaId + : spaceName.startsWith('persona:') + ? spaceName.slice('persona:'.length) + : spaceName; + const displayName = typeof boxbrain?.displayName === 'string' + ? boxbrain.displayName + : fallbackDisplayName ?? id; + const profileImageUrl = typeof boxbrain?.profileImageUrl === 'string' ? boxbrain.profileImageUrl : undefined; + + return { + id, + spaceName, + displayName, + profileImageUrl, + }; +} + +export function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.filter((value) => value.trim().length > 0))); +} + +export function jsonObject(values: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + result[key] = value; + } + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index 9a5a478..30b831e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ export * from './adapters'; +export * from './availability'; +export * from './conversation'; export * from './memory'; export * from './persona'; +export * from './schedule'; export * from './timing'; export * from './types'; diff --git a/src/schedule.ts b/src/schedule.ts new file mode 100644 index 0000000..d903a2f --- /dev/null +++ b/src/schedule.ts @@ -0,0 +1,424 @@ +import { randomUUID } from 'node:crypto'; +import type { Fact, IdentityDB, JsonValue } from 'identitydb'; +import type { SpecialDateProvider, StructuredModelAdapter } from './adapters'; +import { setAvailabilityStatus } from './availability'; +import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from './facts'; +import { persistFactDrafts } from './memory'; +import type { + BoxBrainAvailabilityMode, + BoxBrainAvailabilityEntry, + BoxBrainScheduleEvent, + BoxBrainScheduleEventKind, + BoxBrainScheduleScope, + BoxBrainTopicDraft, +} from './types'; + +export interface ScheduleEventDraft { + title: string; + description?: string | undefined; + startAt: string; + endAt: string; + availabilityMode: BoxBrainAvailabilityMode; + availabilityReason?: string | undefined; + kind?: BoxBrainScheduleEventKind | undefined; + topics?: BoxBrainTopicDraft[] | undefined; + metadata?: JsonValue | null | undefined; +} + +export interface ScheduleGenerationResult { + events: ScheduleEventDraft[]; +} + +export interface GenerateScheduleInput { + spaceName: string; + displayName?: string | undefined; + currentDate: string; + scope: BoxBrainScheduleScope; + timezone?: string | undefined; + structuredModel: StructuredModelAdapter; + specialDateProvider?: SpecialDateProvider | undefined; +} + +export interface ListScheduleEventsInput { + spaceName: string; + from?: string | undefined; + until?: string | undefined; +} + +export interface PruneExpiredScheduleInput { + spaceName: string; + referenceTime: string; + graceSeconds?: number | undefined; +} + +export interface PruneScheduleBeforeInput { + spaceName: string; + before: string; +} + +export interface SchedulePruneResult { + deletedEventIds: string[]; +} + +export async function generateSchedule( + db: IdentityDB, + input: GenerateScheduleInput, +): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> { + await ensurePersonaSpace(db, input.spaceName, input.displayName); + + const specialDates = input.specialDateProvider + ? await input.specialDateProvider.listSpecialDates({ + anchorDate: input.currentDate, + scope: input.scope, + timezone: input.timezone, + }) + : []; + + const generated = assertScheduleGenerationResult( + await input.structuredModel.generateObject({ + prompt: await buildSchedulePrompt(db, input, specialDates), + schema: { + type: 'object', + required: ['events'], + }, + metadata: { + boxbrainTask: 'persona.schedule.generate', + scope: input.scope, + currentDate: input.currentDate, + }, + }), + ); + + const events = generated.events.map((event) => toPersistedEvent(event, input.displayName)); + await persistFactDrafts(db, { + spaceName: input.spaceName, + domain: 'persona.schedule', + source: `${input.structuredModel.provider}:${input.structuredModel.model}`, + facts: events.map((event) => ({ + statement: buildScheduleStatement(event), + metadata: jsonObject({ + scheduleEvent: jsonObject({ + id: event.id, + title: event.title, + description: event.description, + startAt: event.startAt, + endAt: event.endAt, + availabilityMode: event.availabilityMode, + availabilityReason: event.availabilityReason, + kind: event.kind, + metadata: event.metadata ?? null, + }), + }), + topics: event.topics, + })), + }); + + const availabilityEntries: BoxBrainAvailabilityEntry[] = []; + for (const event of events) { + availabilityEntries.push(await setAvailabilityStatus(db, { + spaceName: input.spaceName, + mode: event.availabilityMode, + reason: event.availabilityReason ?? event.title, + effectiveFrom: event.startAt, + until: event.endAt, + sourceType: 'schedule', + eventId: event.id, + metadata: { + title: event.title, + kind: event.kind, + }, + })); + } + + return { events, availabilityEntries }; +} + +export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEventsInput): Promise { + const facts = await listFactsInSpace(db, input.spaceName); + const deletedIds = new Set( + facts + .map(parseScheduleDeletionFact) + .filter((entry): entry is { eventId: string } => entry !== null) + .map((entry) => entry.eventId), + ); + + return facts + .map(parseScheduleEventFact) + .filter((event): event is BoxBrainScheduleEvent => event !== null) + .filter((event) => !deletedIds.has(event.id)) + .filter((event) => overlapsRange(event, input.from, input.until)) + .sort((left, right) => { + if (left.startAt !== right.startAt) { + return left.startAt.localeCompare(right.startAt); + } + return left.id.localeCompare(right.id); + }); +} + +export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredScheduleInput): Promise { + const graceMs = (input.graceSeconds ?? 0) * 1000; + const cutoffMs = Date.parse(input.referenceTime) - graceMs; + const events = await listScheduleEvents(db, { spaceName: input.spaceName }); + const toDelete = events.filter((event) => Date.parse(event.endAt) <= cutoffMs); + await writeScheduleDeletionFacts(db, input.spaceName, toDelete, 'expired schedule pruning'); + + return { deletedEventIds: toDelete.map((event) => event.id) }; +} + +export async function pruneScheduleBefore(db: IdentityDB, input: PruneScheduleBeforeInput): Promise { + const cutoffMs = Date.parse(input.before); + const events = await listScheduleEvents(db, { spaceName: input.spaceName }); + const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs); + await writeScheduleDeletionFacts(db, input.spaceName, toDelete, `schedule pruning before ${input.before}`); + + return { deletedEventIds: toDelete.map((event) => event.id) }; +} + +function assertScheduleGenerationResult(value: ScheduleGenerationResult): ScheduleGenerationResult { + if (!value || !Array.isArray(value.events)) { + throw new Error('Structured schedule generation output must include an events array.'); + } + + for (let index = 0; index < value.events.length; index += 1) { + const event = value.events[index]; + if (!event || typeof event.title !== 'string' || event.title.trim().length === 0) { + throw new Error(`Schedule event at index ${index} requires a non-empty title.`); + } + if (typeof event.startAt !== 'string' || typeof event.endAt !== 'string') { + throw new Error(`Schedule event at index ${index} requires startAt and endAt.`); + } + assertValidIsoTimestamp(event.startAt, `Schedule event at index ${index} startAt`); + assertValidIsoTimestamp(event.endAt, `Schedule event at index ${index} endAt`); + if (Date.parse(event.endAt) <= Date.parse(event.startAt)) { + throw new Error(`Schedule event at index ${index} must end after it starts.`); + } + if (event.availabilityMode !== 'online' && event.availabilityMode !== 'do_not_disturb' && event.availabilityMode !== 'offline') { + throw new Error(`Schedule event at index ${index} has an invalid availabilityMode.`); + } + if (event.kind && event.kind !== 'routine' && event.kind !== 'special') { + throw new Error(`Schedule event at index ${index} has an invalid kind.`); + } + if (event.topics && !Array.isArray(event.topics)) { + throw new Error(`Schedule event at index ${index} has invalid topics.`); + } + } + + return value; +} + +async function buildSchedulePrompt( + db: IdentityDB, + input: GenerateScheduleInput, + specialDates: Awaited['listSpecialDates']>>, +): Promise { + const facts = await listFactsInSpace(db, input.spaceName); + const personaFacts = facts + .filter((fact) => { + const domain = getFactDomain(fact); + return domain === 'persona.biography' || domain === 'persona.relationship' || domain === 'persona.profile_image'; + }) + .slice(0, 12) + .map((fact) => `- ${fact.statement}`) + .join('\n'); + const existingEvents = (await listScheduleEvents(db, { spaceName: input.spaceName })) + .slice(-8) + .map((event) => `- ${event.startAt} to ${event.endAt}: ${event.title}`) + .join('\n'); + const dateContext = specialDates.length > 0 + ? specialDates.map((entry) => `- ${entry.date}: ${entry.title}${entry.description ? ` — ${entry.description}` : ''}`).join('\n') + : '- none provided'; + + return [ + 'Create a realistic personal schedule for a synthetic persona.', + 'Most of the time should remain routine. Only introduce special events when justified by the persona history, relationships, assets, or special dates.', + 'It is acceptable for long periods to stay ordinary or repetitive.', + `Scope: ${input.scope}`, + `Anchor date: ${input.currentDate}`, + input.timezone ? `Timezone: ${input.timezone}` : undefined, + `Display name: ${input.displayName ?? inferDisplayName(input.spaceName)}`, + 'Existing persona facts:', + personaFacts || '- none available', + 'Existing schedule continuity:', + existingEvents || '- none available', + 'Special dates from external context:', + dateContext, + 'Return an events array with ISO timestamps, availability mode, and a routine/special kind.', + ] + .filter((line): line is string => Boolean(line)) + .join('\n'); +} + +function toPersistedEvent(event: ScheduleEventDraft, displayName: string | undefined): BoxBrainScheduleEvent { + const id = randomUUID(); + const topics = normalizeEventTopics(event, displayName); + return { + id, + title: event.title.trim(), + description: event.description?.trim() || undefined, + startAt: event.startAt, + endAt: event.endAt, + availabilityMode: event.availabilityMode, + availabilityReason: event.availabilityReason?.trim() || undefined, + kind: event.kind ?? 'routine', + topics, + metadata: event.metadata, + }; +} + +function normalizeEventTopics(event: ScheduleEventDraft, displayName: string | undefined): BoxBrainTopicDraft[] { + const draftTopics = event.topics ?? []; + const names = uniqueStrings([ + displayName ?? '', + event.title, + dateOnly(event.startAt), + ...draftTopics.map((topic) => topic.name), + ]); + + return names.map((name) => { + const matchingDraft = draftTopics.find((topic) => topic.name === name); + if (matchingDraft) { + return matchingDraft; + } + + if (name === dateOnly(event.startAt)) { + return { name, category: 'temporal' }; + } + + if (displayName && name === displayName) { + return { name, category: 'entity' }; + } + + return { name, category: 'concept' }; + }); +} + +function buildScheduleStatement(event: BoxBrainScheduleEvent): string { + const description = event.description ? ` ${event.description}` : ''; + return `${event.startAt} to ${event.endAt}: ${event.title}.${description}`.trim(); +} + +function parseScheduleEventFact(fact: Fact): BoxBrainScheduleEvent | null { + if (getFactDomain(fact) !== 'persona.schedule') { + return null; + } + + const metadata = getJsonObject(fact.metadata); + const payload = getJsonObject(metadata?.scheduleEvent); + if (!payload) { + return null; + } + + const title = payload.title; + const startAt = payload.startAt; + const endAt = payload.endAt; + const availabilityMode = payload.availabilityMode; + if (typeof title !== 'string' || typeof startAt !== 'string' || typeof endAt !== 'string' || typeof availabilityMode !== 'string') { + return null; + } + + return { + id: typeof payload.id === 'string' ? payload.id : fact.id, + title, + description: typeof payload.description === 'string' ? payload.description : undefined, + startAt, + endAt, + availabilityMode: availabilityMode as BoxBrainAvailabilityMode, + availabilityReason: typeof payload.availabilityReason === 'string' ? payload.availabilityReason : undefined, + kind: payload.kind === 'special' ? 'special' : 'routine', + topics: fact.topics.map((topic) => ({ + name: topic.name, + category: topic.category, + granularity: topic.granularity, + description: topic.description, + metadata: topic.metadata, + })), + metadata: payload.metadata ?? null, + }; +} + +function parseScheduleDeletionFact(fact: Fact): { eventId: string } | null { + if (getFactDomain(fact) !== 'persona.schedule.deleted') { + return null; + } + + const metadata = getJsonObject(fact.metadata); + const deletion = getJsonObject(metadata?.scheduleDeletion); + if (!deletion || typeof deletion.eventId !== 'string') { + return null; + } + + return { eventId: deletion.eventId }; +} + +async function writeScheduleDeletionFacts( + db: IdentityDB, + spaceName: string, + events: BoxBrainScheduleEvent[], + reason: string, +): Promise { + if (events.length === 0) { + return; + } + + await persistFactDrafts(db, { + spaceName, + domain: 'persona.schedule.deleted', + source: 'boxbrain:schedule.prune', + facts: events.map((event) => ({ + statement: `Deleted schedule event ${event.title} (${event.id}).`, + metadata: jsonObject({ + scheduleDeletion: jsonObject({ + eventId: event.id, + deletedAt: new Date().toISOString(), + reason, + }), + }), + topics: [ + { name: dateOnly(event.startAt), category: 'temporal' }, + { name: event.title, category: 'concept' }, + ], + })), + }); +} + +function overlapsRange(event: BoxBrainScheduleEvent, from: string | undefined, until: string | undefined): boolean { + if (!from && !until) { + return true; + } + + const startMs = Date.parse(event.startAt); + const endMs = Date.parse(event.endAt); + const fromMs = from ? Date.parse(from) : Number.NEGATIVE_INFINITY; + const untilMs = until ? Date.parse(until) : Number.POSITIVE_INFINITY; + return startMs < untilMs && endMs > fromMs; +} + +async function ensurePersonaSpace(db: IdentityDB, spaceName: string, displayName: string | undefined): Promise { + const existing = await db.getSpaceByName(spaceName); + const existingMetadata = getJsonObject(existing?.metadata) ?? {}; + const existingBoxBrain = getJsonObject(existingMetadata.boxbrain) ?? {}; + + await db.upsertSpace({ + name: spaceName, + metadata: jsonObject({ + ...existingMetadata, + boxbrain: jsonObject({ + ...existingBoxBrain, + domain: 'persona.space', + personaId: typeof existingBoxBrain.personaId === 'string' ? existingBoxBrain.personaId : inferDisplayName(spaceName), + displayName: displayName ?? (typeof existingBoxBrain.displayName === 'string' ? existingBoxBrain.displayName : inferDisplayName(spaceName)), + profileImageUrl: typeof existingBoxBrain.profileImageUrl === 'string' ? existingBoxBrain.profileImageUrl : undefined, + }), + }), + }); +} + +function inferDisplayName(spaceName: string): string { + return spaceName.startsWith('persona:') ? spaceName.slice('persona:'.length) : spaceName; +} + +function assertValidIsoTimestamp(value: string, fieldName: string): void { + if (!Number.isFinite(Date.parse(value))) { + throw new Error(`${fieldName} must be a valid ISO timestamp.`); + } +} diff --git a/src/types.ts b/src/types.ts index 7b6a0c7..4af19ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,12 +4,17 @@ export type BoxBrainFactDomain = | 'persona.biography' | 'persona.profile_image' | 'persona.schedule' + | 'persona.schedule.deleted' | 'persona.availability' | 'persona.conversation' | 'persona.relationship' | (string & {}); export type BoxBrainAvailabilityMode = 'online' | 'do_not_disturb' | 'offline'; +export type BoxBrainAvailabilitySourceType = 'default' | 'schedule' | 'manual' | 'tool'; +export type BoxBrainScheduleScope = 'day' | 'week' | 'month'; +export type BoxBrainScheduleEventKind = 'routine' | 'special'; +export type BoxBrainConversationDirection = 'inbound' | 'outbound'; export interface BoxBrainTopicDraft { name: string; @@ -38,6 +43,8 @@ export interface BoxBrainAvailability { export interface BoxBrainMessage { text: string; typingDelaySeconds: number; + replyDelaySeconds: number; + totalDelaySeconds: number; } export interface BoxBrainPersonaProfile { @@ -46,3 +53,60 @@ export interface BoxBrainPersonaProfile { displayName: string; profileImageUrl?: string | undefined; } + +export interface BoxBrainScheduleEvent { + id: string; + title: string; + description?: string | undefined; + startAt: string; + endAt: string; + availabilityMode: BoxBrainAvailabilityMode; + availabilityReason?: string | undefined; + kind: BoxBrainScheduleEventKind; + topics: BoxBrainTopicDraft[]; + metadata?: JsonValue | null | undefined; +} + +export interface BoxBrainAvailabilityEntry { + id: string; + mode: BoxBrainAvailabilityMode; + reason?: string | undefined; + effectiveFrom: string; + until?: string | undefined; + sourceType: BoxBrainAvailabilitySourceType; + eventId?: string | undefined; + createdAt?: string | undefined; + metadata?: JsonValue | null | undefined; +} + +export interface BoxBrainAvailabilitySnapshot { + current: BoxBrainAvailabilityEntry; + next: BoxBrainAvailabilityEntry | null; +} + +export interface BoxBrainConversationEntry { + id: string; + turnId: string; + direction: BoxBrainConversationDirection; + text: string; + occurredAt: string; + createdAt?: string | undefined; + counterpartId: string; + counterpartDisplayName?: string | undefined; + proactive: boolean; + metadata?: JsonValue | null | undefined; +} + +export interface BoxBrainMemoryReference { + id: string; + domain: BoxBrainFactDomain; + statement: string; + summary: string; + occurredAt?: string | undefined; + metadata?: JsonValue | null | undefined; +} + +export interface BoxBrainToolCall = Record> { + name: string; + arguments: TArgs; +} diff --git a/tests/availability.test.ts b/tests/availability.test.ts new file mode 100644 index 0000000..5bbca61 --- /dev/null +++ b/tests/availability.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + generateSchedule, + getAvailabilitySnapshot, + setAvailabilityStatus, + type ScheduleGenerationResult, + type StructuredModelAdapter, +} from '../src'; +import { closeOpenDbs, createDb } from './helpers'; + +afterEach(closeOpenDbs); + +describe('availability status APIs', () => { + it('defaults to online when no explicit status exists', async () => { + const db = await createDb(); + + const snapshot = await getAvailabilitySnapshot(db, { + spaceName: 'persona:minji', + at: '2026-05-12T08:00:00.000Z', + }); + + expect(snapshot.current.mode).toBe('online'); + expect(snapshot.current.sourceType).toBe('default'); + }); + + it('derives current and next availability from the stored schedule', async () => { + const db = await createDb(); + await seedSchedule(db); + + const classTime = await getAvailabilitySnapshot(db, { + spaceName: 'persona:minji', + at: '2026-05-12T09:15:00.000Z', + }); + expect(classTime.current.mode).toBe('do_not_disturb'); + expect(classTime.current.reason).toBe('In class'); + expect(classTime.next?.mode).toBe('online'); + expect(classTime.next?.effectiveFrom).toBe('2026-05-12T10:30:00.000Z'); + + const afterDinner = await getAvailabilitySnapshot(db, { + spaceName: 'persona:minji', + at: '2026-05-12T21:30:00.000Z', + }); + expect(afterDinner.current.mode).toBe('offline'); + expect(afterDinner.current.until).toBe('2026-05-12T22:00:00.000Z'); + }); + + it('allows explicit status updates that override schedule-derived status', async () => { + const db = await createDb(); + await seedSchedule(db); + + await setAvailabilityStatus(db, { + spaceName: 'persona:minji', + mode: 'online', + reason: 'Taking a quick break before class starts again.', + effectiveFrom: '2026-05-12T09:10:00.000Z', + until: '2026-05-12T09:40:00.000Z', + sourceType: 'tool', + }); + + const snapshot = await getAvailabilitySnapshot(db, { + spaceName: 'persona:minji', + at: '2026-05-12T09:20:00.000Z', + }); + + expect(snapshot.current.mode).toBe('online'); + expect(snapshot.current.sourceType).toBe('tool'); + expect(snapshot.current.reason).toContain('quick break'); + expect(snapshot.next?.mode).toBe('do_not_disturb'); + expect(snapshot.next?.effectiveFrom).toBe('2026-05-12T09:40:00.000Z'); + }); + + it('keeps caller metadata separate from reserved availability fields', async () => { + const db = await createDb(); + + const entry = await setAvailabilityStatus(db, { + spaceName: 'persona:minji', + mode: 'online', + effectiveFrom: '2026-05-12T08:00:00.000Z', + metadata: { + id: 'caller-id', + mode: 'offline', + note: 'still just metadata', + }, + }); + + expect(entry.id).not.toBe('caller-id'); + expect(entry.mode).toBe('online'); + expect(entry.metadata).toEqual({ + id: 'caller-id', + mode: 'offline', + note: 'still just metadata', + }); + }); + + it('rejects invalid availability timestamps', async () => { + const db = await createDb(); + + await expect(setAvailabilityStatus(db, { + spaceName: 'persona:minji', + mode: 'online', + effectiveFrom: 'not-a-date', + })).rejects.toThrow('Availability effectiveFrom must be a valid ISO timestamp.'); + }); +}); + +async function seedSchedule(db: Awaited>) { + const structured: StructuredModelAdapter = { + provider: 'fake-structured', + model: 'schedule-model', + async generateObject(): Promise { + return { + events: [ + { + title: 'Morning lecture', + description: 'Regular major lecture on campus.', + startAt: '2026-05-12T09:00:00.000Z', + endAt: '2026-05-12T10:30:00.000Z', + availabilityMode: 'do_not_disturb', + availabilityReason: 'In class', + kind: 'routine', + }, + { + title: 'Family dinner', + description: 'Dinner with family.', + startAt: '2026-05-12T18:00:00.000Z', + endAt: '2026-05-12T22:00:00.000Z', + availabilityMode: 'offline', + availabilityReason: 'Family dinner', + kind: 'special', + }, + ], + } satisfies ScheduleGenerationResult as TOutput; + }, + }; + + await generateSchedule(db, { + spaceName: 'persona:minji', + displayName: 'Minji', + currentDate: '2026-05-12', + scope: 'day', + structuredModel: structured, + }); +} diff --git a/tests/conversation.test.ts b/tests/conversation.test.ts new file mode 100644 index 0000000..c27708e --- /dev/null +++ b/tests/conversation.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + generateSchedule, + getAvailabilitySnapshot, + listConversationEntries, + replyToConversation, + setAvailabilityStatus, + startConversation, + type ConversationMemorySelectionResult, + type ConversationTurnPlan, + type ScheduleGenerationResult, + type StructuredModelAdapter, +} from '../src'; +import { persistFactDrafts } from '../src/memory'; +import { closeOpenDbs, createDb } from './helpers'; + +afterEach(closeOpenDbs); + +describe('conversation APIs', () => { + it('blocks replies while the persona is offline', async () => { + const db = await createDb(); + await db.upsertSpace({ + name: 'persona:minji', + metadata: { boxbrain: { domain: 'persona.space', personaId: 'minji', displayName: 'Minji' } }, + }); + await setAvailabilityStatus(db, { + spaceName: 'persona:minji', + mode: 'offline', + reason: 'Sleeping', + effectiveFrom: '2026-05-12T00:00:00.000Z', + until: '2026-05-12T08:00:00.000Z', + sourceType: 'manual', + }); + + let responseCalls = 0; + const result = await replyToConversation(db, { + spaceName: 'persona:minji', + counterpartId: 'user:shinwoo', + counterpartDisplayName: 'Shinwoo', + message: '지금뭐해', + currentTime: '2026-05-12T07:00:00.000Z', + mandatoryMemoryModel: createSelectionModel([]), + contextualMemoryModel: createSelectionModel([]), + responseModel: { + provider: 'fake-structured', + model: 'response-model', + async generateObject(): Promise { + responseCalls += 1; + return { mode: 'reply', messages: ['안자'] } as TOutput; + }, + }, + }); + + expect(result.blocked).toBe(true); + expect(result.messages).toEqual([]); + expect(responseCalls).toBe(0); + }); + + it('retrieves mandatory/contextual memories, generates DM-style replies, and stores the turn', async () => { + const db = await createDb(); + await seedPersonaMemory(db); + const mandatoryPrompts: string[] = []; + const contextualPrompts: string[] = []; + const responsePrompts: string[] = []; + + const result = await replyToConversation(db, { + spaceName: 'persona:minji', + counterpartId: 'user:shinwoo', + counterpartDisplayName: 'Shinwoo', + message: '오늘저녁뭐해?', + currentTime: '2026-05-12T12:00:00.000Z', + mandatoryMemoryModel: createSelectionModel(['m1', 'm2'], mandatoryPrompts), + contextualMemoryModel: createSelectionModel(['m3'], contextualPrompts), + responseModel: createResponseModel( + { + mode: 'reply', + messages: ['저녁엔가족이랑먹어', '왜궁금해'], + }, + responsePrompts, + ), + rng: () => 0, + }); + + expect(mandatoryPrompts[0]).toContain('yesterday, today, and tomorrow schedules'); + expect(contextualPrompts[0]).toContain('오늘저녁뭐해?'); + expect(responsePrompts[0]).toContain('family dinner'); + expect(result.messages).toHaveLength(2); + expect(result.messages[0]?.replyDelaySeconds).toBe(1); + expect(result.messages[0]?.typingDelaySeconds).toBeGreaterThan(0); + expect(result.messages[1]?.replyDelaySeconds).toBe(0); + expect(result.usedMemories).toHaveLength(3); + + const history = await listConversationEntries(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo' }); + expect(history.map((entry) => `${entry.direction}:${entry.text}`)).toEqual([ + 'inbound:오늘저녁뭐해?', + 'outbound:저녁엔가족이랑먹어', + 'outbound:왜궁금해', + ]); + }); + + it('can proactively start a conversation without inbound user text', async () => { + const db = await createDb(); + await seedPersonaMemory(db); + + const result = await startConversation(db, { + spaceName: 'persona:minji', + counterpartId: 'user:shinwoo', + counterpartDisplayName: 'Shinwoo', + currentTime: '2026-05-12T15:00:00.000Z', + mandatoryMemoryModel: createSelectionModel(['m1']), + contextualMemoryModel: createSelectionModel([]), + responseModel: createResponseModel({ mode: 'reply', messages: ['지금뭐해'] }), + rng: () => 0, + }); + + expect(result.messages.map((message) => message.text)).toEqual(['지금뭐해']); + const history = await listConversationEntries(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo' }); + expect(history[0]?.proactive).toBe(true); + }); + + it('executes availability tool calls after farewell-style refusal messages', async () => { + const db = await createDb(); + await seedPersonaMemory(db); + await generateSchedule(db, { + spaceName: 'persona:minji', + displayName: 'Minji', + currentDate: '2026-05-12', + scope: 'day', + structuredModel: { + provider: 'fake-structured', + model: 'schedule-model', + async generateObject(): Promise { + return { + events: [ + { + title: 'Exam', + startAt: '2026-05-12T10:00:00.000Z', + endAt: '2026-05-12T12:00:00.000Z', + availabilityMode: 'offline', + availabilityReason: 'Exam starting', + kind: 'special', + }, + ], + } satisfies ScheduleGenerationResult as TOutput; + }, + }, + }); + + const prompts: string[] = []; + const result = await startConversation(db, { + spaceName: 'persona:minji', + counterpartId: 'user:shinwoo', + counterpartDisplayName: 'Shinwoo', + currentTime: '2026-05-12T09:55:00.000Z', + mandatoryMemoryModel: createSelectionModel(['m1']), + contextualMemoryModel: createSelectionModel([]), + responseModel: createResponseModel( + { + mode: 'refuse', + messages: ['나이제가봐야해', '이따연락해'], + toolCalls: [ + { + name: 'setAvailabilityStatus', + arguments: { + mode: 'offline', + reason: 'Exam starting', + effectiveFrom: '2026-05-12T09:58:00.000Z', + until: '2026-05-12T12:00:00.000Z', + }, + }, + ], + }, + prompts, + ), + }); + + expect(prompts[0]).toContain('next availability transition'); + expect(result.messages.map((message) => message.text)).toEqual(['나이제가봐야해', '이따연락해']); + + const snapshot = await getAvailabilitySnapshot(db, { + spaceName: 'persona:minji', + at: '2026-05-12T10:00:00.000Z', + }); + expect(snapshot.current.mode).toBe('offline'); + expect(snapshot.current.reason).toBe('Exam starting'); + }); +}); + +function createSelectionModel(memoryIds: string[], prompts: string[] = []): StructuredModelAdapter { + return { + provider: 'fake-structured', + model: 'selection-model', + async generateObject(request: { prompt: string }): Promise { + prompts.push(request.prompt); + return { memoryIds } satisfies ConversationMemorySelectionResult as TOutput; + }, + }; +} + +function createResponseModel(plan: ConversationTurnPlan, prompts: string[] = []): StructuredModelAdapter { + return { + provider: 'fake-structured', + model: 'response-model', + async generateObject(request: { prompt: string }): Promise { + prompts.push(request.prompt); + return plan as TOutput; + }, + }; +} + +async function seedPersonaMemory(db: Awaited>) { + await db.upsertSpace({ + name: 'persona:minji', + metadata: { boxbrain: { domain: 'persona.space', personaId: 'minji', displayName: 'Minji' } }, + }); + + await persistFactDrafts(db, { + spaceName: 'persona:minji', + domain: 'persona.biography', + source: 'boxbrain:test', + facts: [ + { + statement: 'Minji prefers quiet evenings and close family dinners.', + topics: [{ name: 'Minji' }, { name: 'family dinner' }], + }, + { + statement: 'Minji knows Shinwoo as a close contact she messages casually.', + topics: [{ name: 'Minji' }, { name: 'Shinwoo' }], + }, + ], + }); + + await generateSchedule(db, { + spaceName: 'persona:minji', + displayName: 'Minji', + currentDate: '2026-05-12', + scope: 'day', + structuredModel: { + provider: 'fake-structured', + model: 'schedule-model', + async generateObject(): Promise { + return { + events: [ + { + title: 'Family dinner', + description: 'Dinner at home with family.', + startAt: '2026-05-12T18:00:00.000Z', + endAt: '2026-05-12T20:00:00.000Z', + availabilityMode: 'offline', + availabilityReason: 'family dinner', + kind: 'special', + }, + ], + } satisfies ScheduleGenerationResult as TOutput; + }, + }, + }); +} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..6f1e151 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,16 @@ +import { IdentityDB } from 'identitydb'; + +const openDbs: IdentityDB[] = []; + +export async function createDb() { + const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' }); + await db.initialize(); + openDbs.push(db); + return db; +} + +export async function closeOpenDbs() { + while (openDbs.length > 0) { + await openDbs.pop()!.close(); + } +} diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index cf95c69..c37dc90 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest'; import { createReplyDelay, createTypingDelay, + generateSchedule, ONLINE_AVAILABILITY, + replyToConversation, type BoxBrainFactDraft, + type SpecialDateProvider, type TextModelAdapter, } from '../src'; @@ -34,4 +37,16 @@ describe('public API', () => { expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']); }); + + it('exports schedule, conversation, and external special-date adapter contracts', () => { + const specialDateProvider: SpecialDateProvider = { + async listSpecialDates() { + return [{ date: '2026-05-08', title: 'Parents Day' }]; + }, + }; + + expect(typeof generateSchedule).toBe('function'); + expect(typeof replyToConversation).toBe('function'); + expect(specialDateProvider.listSpecialDates).toBeTypeOf('function'); + }); }); diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts new file mode 100644 index 0000000..caf3ed4 --- /dev/null +++ b/tests/schedule.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + generateSchedule, + getAvailabilitySnapshot, + listScheduleEvents, + pruneExpiredSchedule, + pruneScheduleBefore, + type ScheduleGenerationResult, + type SpecialDateProvider, + type StructuredModelAdapter, +} from '../src'; +import { closeOpenDbs, createDb } from './helpers'; + +afterEach(closeOpenDbs); + +describe('generateSchedule', () => { + it('generates schedule events, stores them, and includes special date context in the prompt', async () => { + const db = await createDb(); + const prompts: string[] = []; + const structured: StructuredModelAdapter = { + provider: 'fake-structured', + model: 'schedule-model', + async generateObject(request: { prompt: string }): Promise { + prompts.push(request.prompt); + return { + events: [ + { + title: 'Morning lecture', + description: 'Regular major lecture on campus.', + startAt: '2026-05-12T09:00:00.000Z', + endAt: '2026-05-12T10:30:00.000Z', + availabilityMode: 'do_not_disturb', + availabilityReason: 'In class', + kind: 'routine', + topics: [{ name: 'lecture', category: 'concept' }], + }, + { + title: 'Birthday dinner', + description: 'Family dinner for a parent birthday.', + startAt: '2026-05-12T18:00:00.000Z', + endAt: '2026-05-12T20:00:00.000Z', + availabilityMode: 'offline', + availabilityReason: 'Family dinner', + kind: 'special', + topics: [{ name: 'birthday dinner', category: 'concept' }], + }, + ], + } satisfies ScheduleGenerationResult as TOutput; + }, + }; + const specialDateProvider: SpecialDateProvider = { + async listSpecialDates() { + return [ + { + date: '2026-05-12', + title: 'Parents Day', + description: 'A family-focused observance in Korea.', + }, + ]; + }, + }; + + const result = await generateSchedule(db, { + spaceName: 'persona:minji', + displayName: 'Minji', + currentDate: '2026-05-12', + scope: 'day', + timezone: 'Asia/Seoul', + structuredModel: structured, + specialDateProvider, + }); + + expect(prompts[0]).toContain('Parents Day'); + expect(prompts[0]).toContain('Most of the time should remain routine'); + expect(result.events.map((event) => event.title)).toEqual(['Morning lecture', 'Birthday dinner']); + expect(result.availabilityEntries.map((entry) => entry.mode)).toEqual(['do_not_disturb', 'offline']); + + const stored = await listScheduleEvents(db, { spaceName: 'persona:minji' }); + expect(stored.map((event) => event.title)).toEqual(['Morning lecture', 'Birthday dinner']); + expect(stored[0]?.topics.map((topic) => topic.name)).toContain('2026-05-12'); + }); + + it('can prune events that ended before a reference time', async () => { + const db = await createDb(); + await seedSchedule(db); + + const pruned = await pruneExpiredSchedule(db, { + spaceName: 'persona:minji', + referenceTime: '2026-05-12T12:00:00.000Z', + graceSeconds: 0, + }); + + expect(pruned.deletedEventIds).toHaveLength(1); + expect((await listScheduleEvents(db, { spaceName: 'persona:minji' })).map((event) => event.title)).toEqual(['Evening shift']); + const snapshot = await getAvailabilitySnapshot(db, { + spaceName: 'persona:minji', + at: '2026-05-12T09:30:00.000Z', + }); + expect(snapshot.current.mode).toBe('online'); + expect(snapshot.current.sourceType).toBe('default'); + }); + + it('can prune events scheduled before a cutoff date', async () => { + const db = await createDb(); + await seedSchedule(db); + + const pruned = await pruneScheduleBefore(db, { + spaceName: 'persona:minji', + before: '2026-05-12T18:00:00.000Z', + }); + + expect(pruned.deletedEventIds).toHaveLength(1); + expect((await listScheduleEvents(db, { spaceName: 'persona:minji' })).map((event) => event.title)).toEqual(['Evening shift']); + }); + + it('rejects invalid schedule timestamps from the structured model', async () => { + const db = await createDb(); + const structured: StructuredModelAdapter = { + provider: 'fake-structured', + model: 'schedule-model', + async generateObject(): Promise { + return { + events: [ + { + title: 'Broken event', + startAt: 'not-a-date', + endAt: '2026-05-12T10:00:00.000Z', + availabilityMode: 'online', + }, + ], + } satisfies ScheduleGenerationResult as TOutput; + }, + }; + + await expect(generateSchedule(db, { + spaceName: 'persona:minji', + displayName: 'Minji', + currentDate: '2026-05-12', + scope: 'day', + structuredModel: structured, + })).rejects.toThrow('Schedule event at index 0 startAt must be a valid ISO timestamp.'); + }); +}); + +async function seedSchedule(db: Awaited>) { + const structured: StructuredModelAdapter = { + provider: 'fake-structured', + model: 'schedule-model', + async generateObject(): Promise { + return { + events: [ + { + title: 'Morning lecture', + startAt: '2026-05-12T09:00:00.000Z', + endAt: '2026-05-12T10:30:00.000Z', + availabilityMode: 'do_not_disturb', + kind: 'routine', + }, + { + title: 'Evening shift', + startAt: '2026-05-12T18:00:00.000Z', + endAt: '2026-05-12T22:00:00.000Z', + availabilityMode: 'offline', + kind: 'routine', + }, + ], + } satisfies ScheduleGenerationResult as TOutput; + }, + }; + + await generateSchedule(db, { + spaceName: 'persona:minji', + displayName: 'Minji', + currentDate: '2026-05-12', + scope: 'day', + structuredModel: structured, + }); +}