feat: add BoxBrain persona runtime APIs

This commit is contained in:
2026-05-11 17:01:19 +09:00
parent c5a3d7e835
commit 3ee6b233ea
13 changed files with 2043 additions and 9 deletions

View File

@@ -1,22 +1,28 @@
# BoxBrain # 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 - one IdentityDB memory space per persona
- persona initialization from personality, history, values, preferences, and relationships - persona initialization from personality, history, values, preferences, and relationships
- LLM-generated biography ingestion into IdentityDB fact drafts - LLM-generated biography ingestion into IdentityDB fact drafts
- optional profile image generation through an image adapter - 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 - 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 ## Development
@@ -29,6 +35,6 @@ bun run build
## Current status ## 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` - `docs/plans/2026-05-11-boxbrain-foundation.md`

View File

@@ -1,4 +1,5 @@
import type { JsonValue } from 'identitydb'; import type { JsonValue } from 'identitydb';
import type { BoxBrainScheduleScope } from './types';
export interface TextGenerationRequest { export interface TextGenerationRequest {
prompt: string; prompt: string;
@@ -42,3 +43,19 @@ export interface ImageModelAdapter {
model: string; model: string;
generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResult>; generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResult>;
} }
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<SpecialDateContext[]>;
}

272
src/availability.ts Normal file
View File

@@ -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<BoxBrainAvailabilitySourceType, 'default'> | 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<Exclude<BoxBrainAvailabilitySourceType, 'default'>, number> = {
schedule: 1,
manual: 2,
tool: 3,
};
export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
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<BoxBrainAvailabilityEntry[]> {
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<BoxBrainAvailabilitySnapshot> {
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<string> {
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<BoxBrainAvailabilitySourceType, 'default'> {
return sourceType === 'schedule' || sourceType === 'manual' || sourceType === 'tool';
}
function priorityOf(sourceType: BoxBrainAvailabilitySourceType): number {
if (sourceType === 'default') {
return 0;
}
return EXPLICIT_SOURCE_PRIORITY[sourceType];
}

543
src/conversation.ts Normal file
View File

@@ -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<string, JsonValue> {
mode: BoxBrainAvailabilityMode;
reason?: string;
effectiveFrom?: string;
until?: string;
}
export interface ConversationToolCall extends BoxBrainToolCall<SetAvailabilityToolArguments> {
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<ConversationTurnResult> {
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<ConversationTurnResult> {
return generateConversationTurn(db, {
...input,
proactive: true,
turnId: randomUUID(),
});
}
export async function listConversationEntries(
db: IdentityDB,
input: ListConversationEntriesInput,
): Promise<BoxBrainConversationEntry[]> {
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<ConversationTurnResult> {
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<string, BoxBrainMemoryReference>(
candidateMemories.map((memory, index) => [`m${index + 1}`, memory]),
);
const mandatorySelection = assertConversationMemorySelectionResult(
await input.mandatoryMemoryModel.generateObject<ConversationMemorySelectionResult>({
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<ConversationMemorySelectionResult>({
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<ConversationTurnPlan>({
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<BoxBrainMemoryReference[]> {
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, BoxBrainMemoryReference>,
): 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, BoxBrainMemoryReference>,
): 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<ReturnType<typeof getAvailabilitySnapshot>>,
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, BoxBrainMemoryReference>): 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<void> {
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<string, BoxBrainMemoryReference>();
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<ConversationToolCall[]> {
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;
}

95
src/facts.ts Normal file
View File

@@ -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<Fact[]> {
const topics = await db.listTopics({ includeFacts: true, spaceName });
const byId = new Map<string, Fact>();
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<string, JsonValue> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, JsonValue>;
}
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<BoxBrainPersonaProfile> {
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<string, JsonValue | undefined>): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
for (const [key, value] of Object.entries(values)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}

View File

@@ -1,5 +1,8 @@
export * from './adapters'; export * from './adapters';
export * from './availability';
export * from './conversation';
export * from './memory'; export * from './memory';
export * from './persona'; export * from './persona';
export * from './schedule';
export * from './timing'; export * from './timing';
export * from './types'; export * from './types';

424
src/schedule.ts Normal file
View File

@@ -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<ScheduleGenerationResult>({
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<BoxBrainScheduleEvent[]> {
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<SchedulePruneResult> {
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<SchedulePruneResult> {
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<ReturnType<NonNullable<GenerateScheduleInput['specialDateProvider']>['listSpecialDates']>>,
): Promise<string> {
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<void> {
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<void> {
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.`);
}
}

View File

@@ -4,12 +4,17 @@ export type BoxBrainFactDomain =
| 'persona.biography' | 'persona.biography'
| 'persona.profile_image' | 'persona.profile_image'
| 'persona.schedule' | 'persona.schedule'
| 'persona.schedule.deleted'
| 'persona.availability' | 'persona.availability'
| 'persona.conversation' | 'persona.conversation'
| 'persona.relationship' | 'persona.relationship'
| (string & {}); | (string & {});
export type BoxBrainAvailabilityMode = 'online' | 'do_not_disturb' | 'offline'; 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 { export interface BoxBrainTopicDraft {
name: string; name: string;
@@ -38,6 +43,8 @@ export interface BoxBrainAvailability {
export interface BoxBrainMessage { export interface BoxBrainMessage {
text: string; text: string;
typingDelaySeconds: number; typingDelaySeconds: number;
replyDelaySeconds: number;
totalDelaySeconds: number;
} }
export interface BoxBrainPersonaProfile { export interface BoxBrainPersonaProfile {
@@ -46,3 +53,60 @@ export interface BoxBrainPersonaProfile {
displayName: string; displayName: string;
profileImageUrl?: string | undefined; 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<TArgs extends Record<string, JsonValue> = Record<string, JsonValue>> {
name: string;
arguments: TArgs;
}

143
tests/availability.test.ts Normal file
View File

@@ -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<ReturnType<typeof createDb>>) {
const structured: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
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,
});
}

258
tests/conversation.test.ts Normal file
View File

@@ -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<TOutput>(): Promise<TOutput> {
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<TOutput>(): Promise<TOutput> {
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<TOutput>(request: { prompt: string }): Promise<TOutput> {
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<TOutput>(request: { prompt: string }): Promise<TOutput> {
prompts.push(request.prompt);
return plan as TOutput;
},
};
}
async function seedPersonaMemory(db: Awaited<ReturnType<typeof createDb>>) {
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<TOutput>(): Promise<TOutput> {
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;
},
},
});
}

16
tests/helpers.ts Normal file
View File

@@ -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();
}
}

View File

@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
import { import {
createReplyDelay, createReplyDelay,
createTypingDelay, createTypingDelay,
generateSchedule,
ONLINE_AVAILABILITY, ONLINE_AVAILABILITY,
replyToConversation,
type BoxBrainFactDraft, type BoxBrainFactDraft,
type SpecialDateProvider,
type TextModelAdapter, type TextModelAdapter,
} from '../src'; } from '../src';
@@ -34,4 +37,16 @@ describe('public API', () => {
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']); 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');
});
}); });

178
tests/schedule.test.ts Normal file
View File

@@ -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<TOutput>(request: { prompt: string }): Promise<TOutput> {
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<TOutput>(): Promise<TOutput> {
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<ReturnType<typeof createDb>>) {
const structured: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
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,
});
}