feat: add BoxBrain persona runtime APIs
This commit is contained in:
24
README.md
24
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`
|
||||
|
||||
@@ -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<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
272
src/availability.ts
Normal 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
543
src/conversation.ts
Normal 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
95
src/facts.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
424
src/schedule.ts
Normal file
424
src/schedule.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
64
src/types.ts
64
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<TArgs extends Record<string, JsonValue> = Record<string, JsonValue>> {
|
||||
name: string;
|
||||
arguments: TArgs;
|
||||
}
|
||||
|
||||
143
tests/availability.test.ts
Normal file
143
tests/availability.test.ts
Normal 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
258
tests/conversation.test.ts
Normal 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
16
tests/helpers.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
178
tests/schedule.test.ts
Normal file
178
tests/schedule.test.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user