feat: bootstrap BoxBrain framework
Some checks failed
CI / verify (push) Failing after 3s

This commit is contained in:
2026-05-14 19:30:34 +09:00
commit c047c5a23d
16 changed files with 1846 additions and 0 deletions

64
src/conversation.ts Normal file
View File

@@ -0,0 +1,64 @@
import type {
BoxBrainMemoryStore,
DateTimeInput,
MandatoryConversationContext,
MemorySpace,
PersonaMessage,
ScheduledAvailabilitySnapshot,
} from './types';
import { addUtcDays, buildAvailabilitySnapshot, dateKeysAround, startOfUtcDay, toIso } from './schedule';
export function formatMessageHistory(input: { personaName: string; messages: PersonaMessage[] }): string {
return input.messages
.map((message) => {
const sender = message.sender === 'persona' ? input.personaName : 'user';
return `${sender}@${toIso(message.time)}: ${message.content}`;
})
.join('\n');
}
export function conversationInstruction(): string {
return [
'You are controlling the persona, not a generic assistant.',
'Use the send_message tool conceptually: return one or more outgoing messages.',
'Unless the persona strongly prefers otherwise, keep each outgoing message to at most one sentence.',
'Prefer short, natural, chat-like wording and allow splitting one thought into multiple messages.',
'If mandatory memory says "기억이 없음", the persona may naturally wonder about missing context instead of pretending to remember.',
].join('\n');
}
export function memoryExtractionInstruction(now: string): string {
return [
`Current objective time: ${now}.`,
'Read the message history and extract durable facts worth remembering.',
'Objectivize subjective statements before storage.',
'Example: "I started TypeScript in 2025" becomes "The user started TypeScript in 2025."',
'Prefer facts about the persona, the user, their relationship, preferences, history, schedule-relevant events, and stable traits.',
].join('\n');
}
export async function buildMandatoryConversationContext(input: {
persona: MemorySpace;
now: DateTimeInput;
memory: BoxBrainMemoryStore;
messages: PersonaMessage[];
availability: ScheduledAvailabilitySnapshot;
}): Promise<MandatoryConversationContext> {
const now = startOfUtcDay(input.now);
const from = addUtcDays(now, -1).toISOString();
const to = addUtcDays(now, 2).toISOString();
const scheduleEntries = await input.memory.listScheduleEntries(input.persona.id, from, to);
const personaAndUserFacts = await input.memory.findFacts(input.persona.id, ['persona', input.persona.displayName, 'user']);
const memorySummary = personaAndUserFacts.length === 0
? '기억이 없음'
: personaAndUserFacts.map((fact) => `- ${fact.statement}`).join('\n');
return {
formattedMessageHistory: formatMessageHistory({ personaName: input.persona.displayName, messages: input.messages }),
conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(', ')}.`,
memorySummary,
personaAndUserFacts,
scheduleEntries,
availability: input.availability,
};
}

5
src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from './types';
export * from './memory';
export * from './schedule';
export * from './conversation';
export * from './persona';

178
src/memory.ts Normal file
View File

@@ -0,0 +1,178 @@
import { IdentityDB, type Fact as IdentityFact } from 'identitydb';
import type { BoxBrainMemoryStore, FactDraft, MemorySpace, ScheduleEntry, StoredFact } from './types';
function normalizeTopics(topics: string[]): string[] {
return [...new Set(topics.map((topic) => topic.trim()).filter(Boolean))];
}
function includesAnyTopic(fact: StoredFact, topics: string[]): boolean {
const normalized = new Set(normalizeTopics(topics).map((topic) => topic.toLowerCase()));
return fact.topics.some((topic) => normalized.has(topic.toLowerCase()));
}
export class InMemoryMemoryStore implements BoxBrainMemoryStore {
readonly spaces = new Map<string, MemorySpace>();
readonly facts = new Map<string, StoredFact[]>();
readonly schedules = new Map<string, ScheduleEntry[]>();
async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace> {
const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona';
const space: MemorySpace = {
id: `persona-${slug}-${crypto.randomUUID()}`,
displayName: input.displayName,
createdAt: input.now,
metadata: { seedMessage: input.seedMessage },
};
this.spaces.set(space.id, space);
return space;
}
async getSpace(spaceId: string): Promise<MemorySpace | null> {
return this.spaces.get(spaceId) ?? null;
}
async addFact(spaceId: string, fact: FactDraft): Promise<StoredFact> {
const stored: StoredFact = {
id: crypto.randomUUID(),
statement: fact.statement,
topics: normalizeTopics(fact.topics),
createdAt: new Date().toISOString(),
...(fact.confidence === undefined ? {} : { confidence: fact.confidence }),
...(fact.source === undefined ? {} : { source: fact.source }),
...(fact.metadata === undefined ? {} : { metadata: fact.metadata }),
};
const existing = this.facts.get(spaceId) ?? [];
existing.push(stored);
this.facts.set(spaceId, existing);
return stored;
}
async findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]> {
const facts = this.facts.get(spaceId) ?? [];
if (topics.length === 0) return [...facts];
return facts.filter((fact) => includesAnyTopic(fact, topics));
}
async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void> {
const existing = (this.schedules.get(spaceId) ?? []).filter((entry) => !entries.some((incoming) => incoming.id === entry.id));
this.schedules.set(spaceId, [...existing, ...entries].sort((a, b) => a.startAt.localeCompare(b.startAt)));
}
async listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]> {
return (this.schedules.get(spaceId) ?? [])
.filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive)
.sort((a, b) => a.startAt.localeCompare(b.startAt));
}
async deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number> {
const entries = this.schedules.get(spaceId) ?? [];
const kept = entries.filter((entry) => entry.endAt > cutoffExclusive);
this.schedules.set(spaceId, kept);
return entries.length - kept.length;
}
}
export interface IdentityDbMemoryStoreOptions {
db: IdentityDB;
}
export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
constructor(private readonly options: IdentityDbMemoryStoreOptions) {}
async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace> {
const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona';
const spaceName = `persona-${slug}-${crypto.randomUUID()}`;
const space = await this.options.db.upsertSpace({
name: spaceName,
description: `BoxBrain persona space for ${input.displayName}`,
metadata: {
boxbrainType: 'persona-space',
displayName: input.displayName,
seedMessage: input.seedMessage,
createdAt: input.now,
},
});
return {
id: space.name,
displayName: input.displayName,
createdAt: space.createdAt,
metadata: { seedMessage: input.seedMessage, identityDbSpaceId: space.id },
};
}
async getSpace(spaceId: string): Promise<MemorySpace | null> {
const space = await this.options.db.getSpaceByName(spaceId);
if (!space) return null;
const metadata = typeof space.metadata === 'object' && space.metadata !== null && !Array.isArray(space.metadata) ? space.metadata as Record<string, unknown> : {};
const displayName = typeof metadata['displayName'] === 'string' ? metadata['displayName'] : space.name;
return { id: space.name, displayName, createdAt: space.createdAt, metadata };
}
async addFact(spaceId: string, fact: FactDraft): Promise<StoredFact> {
const stored = await this.options.db.addFact({
spaceName: spaceId,
statement: fact.statement,
confidence: fact.confidence ?? null,
source: fact.source ?? null,
metadata: (fact.metadata ?? null) as never,
topics: normalizeTopics(fact.topics).map((topic) => ({ name: topic, category: 'entity' as const, granularity: 'concrete' as const })),
});
return this.fromIdentityFact(stored);
}
async findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]> {
const uniqueTopics = normalizeTopics(topics);
const collected = new Map<string, StoredFact>();
for (const topic of uniqueTopics) {
const facts = await this.options.db.getTopicFacts(topic, { spaceName: spaceId });
for (const fact of facts) {
collected.set(fact.id, this.fromIdentityFact(fact));
}
}
return [...collected.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void> {
for (const entry of entries) {
await this.addFact(spaceId, {
statement: `${entry.title} from ${entry.startAt} to ${entry.endAt}.`,
topics: ['schedule', entry.startAt.slice(0, 10), entry.activity, 'persona'],
source: 'boxbrain.schedule',
metadata: { ...entry.metadata, scheduleEntry: entry },
});
}
}
async listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]> {
const facts = await this.findFacts(spaceId, ['schedule']);
return facts
.map((fact) => fact.metadata?.['scheduleEntry'])
.filter((value): value is ScheduleEntry => typeof value === 'object' && value !== null && !Array.isArray(value))
.filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive)
.sort((a, b) => a.startAt.localeCompare(b.startAt));
}
async deleteScheduleEntriesBefore(_spaceId: string, _cutoffExclusive: string): Promise<number> {
// IdentityDB is append-oriented at the public API level. Record schedule deletion as a fact at the Persona layer.
return 0;
}
private fromIdentityFact(fact: IdentityFact): StoredFact {
const metadata = typeof fact.metadata === 'object' && fact.metadata !== null && !Array.isArray(fact.metadata) ? fact.metadata as Record<string, unknown> : undefined;
return {
id: fact.id,
statement: fact.statement,
topics: fact.topics.map((topic) => topic.name),
createdAt: fact.createdAt,
...(fact.confidence === null ? {} : { confidence: fact.confidence }),
...(fact.source === null ? {} : { source: fact.source }),
...(metadata === undefined ? {} : { metadata }),
};
}
}
export async function createSqliteIdentityMemoryStore(filename: string): Promise<IdentityDbMemoryStore> {
const db = await IdentityDB.connect({ client: 'sqlite', filename });
await db.initialize();
return new IdentityDbMemoryStore({ db });
}

306
src/persona.ts Normal file
View File

@@ -0,0 +1,306 @@
import { InMemoryMemoryStore } from './memory';
import {
addUtcDays,
buildAvailabilitySnapshot,
createMonthlyScheduleEntries,
createTenMinuteDailySchedule,
scheduleTargetDay,
startOfUtcDay,
toIso,
} from './schedule';
import {
buildMandatoryConversationContext,
conversationInstruction,
formatMessageHistory,
memoryExtractionInstruction,
} from './conversation';
import type {
BoxBrainMemoryStore,
DateTimeInput,
DebugEvent,
FactDraft,
MemorySpace,
OutgoingMessageDraft,
PersonaMessage,
PersonaOptions,
ScheduleEntry,
ScheduledAvailabilitySnapshot,
} from './types';
interface CreateMode {
type: 'create';
displayName: string;
seedMessage: string;
}
interface LoadMode {
type: 'load';
spaceId: string;
}
type Mode = CreateMode | LoadMode;
function defaultInitialFact(displayName: string, seedMessage: string): FactDraft {
return {
statement: `${displayName} is a BoxBrain persona initialized from this seed: ${seedMessage}`,
topics: ['persona', displayName],
source: 'boxbrain.persona.initialization',
confidence: 1,
metadata: { boxbrainType: 'persona-initial-fact' },
};
}
function ensureDraft(draft: OutgoingMessageDraft): OutgoingMessageDraft {
return {
messages: draft.messages.map((message) => message.trim()).filter(Boolean),
...(draft.reasoning === undefined ? {} : { reasoning: draft.reasoning }),
};
}
export class Persona {
private readonly memory: BoxBrainMemoryStore;
private readonly options: PersonaOptions;
private readonly mode: Mode;
private readonly readyPromise: Promise<MemorySpace>;
private availabilitySnapshot?: ScheduledAvailabilitySnapshot;
constructor(displayName: string, seedMessage: string, options?: PersonaOptions);
constructor(spaceId: string, options?: PersonaOptions);
constructor(first: string, second?: string | PersonaOptions, third?: PersonaOptions) {
if (typeof second === 'string') {
this.mode = { type: 'create', displayName: first, seedMessage: second };
this.options = third ?? {};
} else {
this.mode = { type: 'load', spaceId: first };
this.options = second ?? {};
}
this.memory = this.options.memory ?? new InMemoryMemoryStore();
this.readyPromise = this.initialize();
}
async ready(): Promise<MemorySpace> {
return this.readyPromise;
}
async createDailySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> {
const persona = await this.ready();
const targetDay = scheduleTargetDay(datetime);
const entries = createTenMinuteDailySchedule({ persona, targetDay, message });
await this.emit('persona.schedule.daily.generated', { targetDay: targetDay.toISOString(), count: entries.length, message });
await this.memory.saveScheduleEntries(persona.id, entries);
await this.refreshAvailability(datetime);
return entries;
}
async createMonthlySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> {
const persona = await this.ready();
const entries = createMonthlyScheduleEntries({ persona, fromDay: datetime, message });
await this.emit('persona.schedule.monthly.generated', { count: entries.length, message });
await this.memory.saveScheduleEntries(persona.id, entries);
await this.refreshAvailability(datetime);
return entries;
}
async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise<number> {
const persona = await this.ready();
const cutoff = toIso(cutoffExclusive);
const deleted = await this.memory.deleteScheduleEntriesBefore(persona.id, cutoff);
await this.memory.addFact(persona.id, {
statement: `Schedules before ${cutoff} were deleted or marked inactive.`,
topics: ['persona.schedule.deleted', 'schedule', cutoff.slice(0, 10)],
source: 'boxbrain.schedule.prune',
metadata: { boxbrainType: 'schedule-deletion', cutoffExclusive: cutoff, deleted },
});
await this.emit('persona.schedule.deleted', { cutoffExclusive: cutoff, deleted });
return deleted;
}
async deleteSchedulesOlderThan(datetime: DateTimeInput): Promise<number> {
return this.deleteSchedulesBefore(datetime);
}
async getTodayScheduledAvailability(datetime: DateTimeInput): Promise<ScheduledAvailabilitySnapshot> {
if (!this.availabilitySnapshot) {
await this.refreshAvailability(datetime);
}
const snapshot = this.availabilitySnapshot;
if (!snapshot) throw new Error('Availability snapshot was not initialized.');
const today = startOfUtcDay(datetime).toISOString();
if (snapshot.windowStartAt !== today) {
await this.refreshAvailability(datetime);
}
const refreshed = this.availabilitySnapshot;
if (!refreshed) throw new Error('Availability snapshot was not initialized.');
return refreshed;
}
async sendMessage(input: {
datetime: DateTimeInput;
messageHistory: PersonaMessage[];
getLatestMessageHistory?: () => Promise<PersonaMessage[]>;
}): Promise<OutgoingMessageDraft> {
const persona = await this.ready();
if (!this.options.models?.conversation) {
throw new Error('sendMessage requires options.models.conversation.');
}
const availability = await this.getTodayScheduledAvailability(input.datetime);
const context = await buildMandatoryConversationContext({
persona,
now: input.datetime,
memory: this.memory,
messages: input.messageHistory,
availability,
});
await this.emit('persona.conversation.context.loaded', {
factCount: context.personaAndUserFacts.length,
scheduleEntryCount: context.scheduleEntries.length,
});
const userMessage = [...input.messageHistory].reverse().find((message) => message.sender === 'user')?.content;
let draft = ensureDraft(await this.options.models.conversation.generateReply({
persona,
now: toIso(input.datetime),
mode: 'reply',
context,
...(userMessage === undefined ? {} : { userMessage }),
instruction: conversationInstruction(),
}));
if (input.getLatestMessageHistory && this.options.models.rewrite) {
const latest = await input.getLatestMessageHistory();
if (latest.length > input.messageHistory.length) {
const latestContext = await buildMandatoryConversationContext({
persona,
now: input.datetime,
memory: this.memory,
messages: latest,
availability,
});
const decision = await this.options.models.rewrite.decide({
persona,
now: toIso(input.datetime),
previousHistory: input.messageHistory,
latestHistory: latest,
draft,
context: latestContext,
});
await this.emit('persona.conversation.rewrite.checked', { rewrite: decision.rewrite, reason: decision.reason ?? null });
if (decision.rewrite) {
draft = ensureDraft(decision.draft ?? await this.options.models.conversation.generateReply({
persona,
now: toIso(input.datetime),
mode: 'reply',
context: latestContext,
instruction: conversationInstruction(),
}));
}
}
}
await this.emit('persona.conversation.reply.generated', { messageCount: draft.messages.length });
return draft;
}
async startConversation(input: {
datetime: DateTimeInput;
messageHistory: PersonaMessage[];
}): Promise<OutgoingMessageDraft> {
const persona = await this.ready();
if (!this.options.models?.conversation) {
throw new Error('startConversation requires options.models.conversation.');
}
const availability = await this.getTodayScheduledAvailability(input.datetime);
const context = await buildMandatoryConversationContext({
persona,
now: input.datetime,
memory: this.memory,
messages: input.messageHistory,
availability,
});
const draft = ensureDraft(await this.options.models.conversation.generateReply({
persona,
now: toIso(input.datetime),
mode: 'start-conversation',
context,
instruction: conversationInstruction(),
}));
await this.emit('persona.conversation.started', { messageCount: draft.messages.length });
return draft;
}
async sleepMemory(input: {
datetime: DateTimeInput;
messageHistory: PersonaMessage[];
}): Promise<FactDraft[]> {
const persona = await this.ready();
if (!this.options.models?.memoryExtraction) {
throw new Error('sleepMemory requires options.models.memoryExtraction.');
}
const contextFacts = await this.memory.findFacts(persona.id, ['persona', persona.displayName, 'user']);
const drafts = await this.options.models.memoryExtraction.extract({
persona,
now: toIso(input.datetime),
formattedMessageHistory: formatMessageHistory({ personaName: persona.displayName, messages: input.messageHistory }),
contextFacts,
instruction: memoryExtractionInstruction(toIso(input.datetime)),
});
for (const draft of drafts) {
await this.memory.addFact(persona.id, {
...draft,
topics: [...draft.topics, 'sleepMemory'],
source: draft.source ?? 'boxbrain.sleepMemory',
});
}
await this.emit('persona.memory.sleep.persisted', { factCount: drafts.length });
return drafts;
}
private async initialize(): Promise<MemorySpace> {
const now = toIso(this.options.now ?? new Date());
if (this.mode.type === 'load') {
const existing = await this.memory.getSpace(this.mode.spaceId);
if (!existing) throw new Error(`Persona space not found: ${this.mode.spaceId}`);
await this.emit('persona.loaded', { displayName: existing.displayName });
await this.refreshAvailability(now, existing);
return existing;
}
const space = await this.memory.createSpace({ displayName: this.mode.displayName, seedMessage: this.mode.seedMessage, now });
const modelFacts = this.options.models?.initialization
? await this.options.models.initialization.extractInitialFacts({
displayName: this.mode.displayName,
seedMessage: this.mode.seedMessage,
now,
})
: undefined;
const facts = modelFacts ?? [defaultInitialFact(this.mode.displayName, this.mode.seedMessage)];
for (const fact of facts) {
await this.memory.addFact(space.id, fact);
}
await this.emit('persona.initialized', { displayName: space.displayName, factCount: facts.length }, space.id);
await this.refreshAvailability(now, space);
return space;
}
private async refreshAvailability(datetime: DateTimeInput, knownPersona?: MemorySpace): Promise<void> {
const persona = knownPersona ?? await this.ready();
const start = startOfUtcDay(datetime);
const end = addUtcDays(start, 2);
const entries = await this.memory.listScheduleEntries(persona.id, start.toISOString(), end.toISOString());
this.availabilitySnapshot = buildAvailabilitySnapshot({ now: datetime, entries });
await this.emit('persona.availability.refreshed', { rangeCount: this.availabilitySnapshot.ranges.length }, persona.id);
}
private async emit(name: string, data?: Record<string, unknown>, explicitSpaceId?: string): Promise<void> {
if (!this.options.debug) return;
const event: DebugEvent = {
name,
time: new Date().toISOString(),
...(explicitSpaceId === undefined ? {} : { spaceId: explicitSpaceId }),
...(data === undefined ? {} : { data }),
};
await this.options.debug(event);
}
}

211
src/schedule.ts Normal file
View File

@@ -0,0 +1,211 @@
import type {
AvailabilityMode,
AvailabilityRange,
DateTimeInput,
MemorySpace,
ScheduleActivity,
ScheduleEntry,
ScheduledAvailabilitySnapshot,
} from './types';
const TEN_MINUTES_MS = 10 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
export function toDate(input: DateTimeInput): Date {
const date = input instanceof Date ? new Date(input.getTime()) : new Date(input);
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid datetime: ${String(input)}`);
}
return date;
}
export function toIso(input: DateTimeInput): string {
return toDate(input).toISOString();
}
export function startOfUtcDay(input: DateTimeInput): Date {
const date = toDate(input);
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
export function addUtcDays(input: DateTimeInput, days: number): Date {
return new Date(startOfUtcDay(input).getTime() + days * DAY_MS);
}
export function scheduleTargetDay(now: DateTimeInput): Date {
return addUtcDays(now, 1);
}
function pick(activity: ScheduleActivity): { title: string; mode: AvailabilityMode } {
switch (activity) {
case 'sleep':
return { title: 'Sleep', mode: 'offline' };
case 'work':
return { title: 'Work', mode: 'do-not-disturb' };
case 'study':
return { title: 'Study', mode: 'do-not-disturb' };
case 'job-search':
return { title: 'Job search', mode: 'do-not-disturb' };
case 'travel':
return { title: 'Travel', mode: 'do-not-disturb' };
case 'commute':
return { title: 'Commute', mode: 'do-not-disturb' };
case 'exercise':
return { title: 'Exercise', mode: 'online' };
case 'meal':
return { title: 'Meal', mode: 'online' };
case 'social':
return { title: 'Social time', mode: 'online' };
case 'errand':
return { title: 'Errand', mode: 'online' };
case 'free-time':
return { title: 'Free time', mode: 'online' };
case 'rest':
return { title: 'Rest', mode: 'online' };
}
}
function chooseDaytimeActivity(message: string): ScheduleActivity {
const lower = message.toLowerCase();
if (lower.includes('travel') || lower.includes('trip') || lower.includes('여행')) return 'travel';
if (lower.includes('study') || lower.includes('exam') || lower.includes('공부') || lower.includes('시험')) return 'study';
if (lower.includes('job') || lower.includes('취업') || lower.includes('구직')) return 'job-search';
if (lower.includes('work') || lower.includes('일') || lower.includes('회사')) return 'work';
return 'work';
}
function activityForMinute(minuteOfDay: number, message: string): ScheduleActivity {
const hour = Math.floor(minuteOfDay / 60);
if (hour < 7) return 'sleep';
if (hour === 7) return 'meal';
if (hour === 8) return 'commute';
if (hour >= 9 && hour < 12) return chooseDaytimeActivity(message);
if (hour === 12) return 'meal';
if (hour >= 13 && hour < 17) return chooseDaytimeActivity(message);
if (hour === 17) return 'commute';
if (hour === 18) return 'meal';
if (hour >= 19 && hour < 21) return message.toLowerCase().includes('study') || message.includes('공부') ? 'study' : 'free-time';
if (hour >= 21 && hour < 23) return 'rest';
return 'sleep';
}
export function createTenMinuteDailySchedule(input: {
persona: MemorySpace;
targetDay: DateTimeInput;
message: string;
}): ScheduleEntry[] {
const target = startOfUtcDay(input.targetDay);
const entries: ScheduleEntry[] = [];
for (let offset = 0; offset < DAY_MS; offset += TEN_MINUTES_MS) {
const start = new Date(target.getTime() + offset);
const end = new Date(start.getTime() + TEN_MINUTES_MS);
const minute = offset / (60 * 1000);
const activity = activityForMinute(minute, input.message);
const picked = pick(activity);
entries.push({
id: crypto.randomUUID(),
spaceId: input.persona.id,
startAt: start.toISOString(),
endAt: end.toISOString(),
activity,
title: picked.title,
description: `Realistic ${picked.title.toLowerCase()} block for ${input.persona.displayName}.`,
granularity: 'ten-minute',
sourceMessage: input.message,
metadata: {
boxbrainType: 'schedule-entry',
availabilityMode: picked.mode,
targetDate: target.toISOString().slice(0, 10),
},
});
}
return entries;
}
export function createMonthlyScheduleEntries(input: {
persona: MemorySpace;
fromDay: DateTimeInput;
message: string;
days?: number;
}): ScheduleEntry[] {
const start = scheduleTargetDay(input.fromDay);
const count = input.days ?? 30;
const entries: ScheduleEntry[] = [];
for (let day = 0; day < count; day += 1) {
const dayStart = new Date(start.getTime() + day * DAY_MS);
const travelHint = day > 0 && day % 90 === 0 ? ' travel' : '';
const activity = chooseDaytimeActivity(`${input.message}${travelHint}`);
const picked = pick(activity);
entries.push({
id: crypto.randomUUID(),
spaceId: input.persona.id,
startAt: dayStart.toISOString(),
endAt: new Date(dayStart.getTime() + DAY_MS).toISOString(),
activity,
title: picked.title,
description: `Daily outline for ${input.persona.displayName}.`,
granularity: 'day',
sourceMessage: input.message,
metadata: {
boxbrainType: 'schedule-entry',
availabilityMode: picked.mode,
targetDate: dayStart.toISOString().slice(0, 10),
},
});
}
return entries;
}
export function availabilityModeForEntry(entry: ScheduleEntry): AvailabilityMode {
const mode = entry.metadata['availabilityMode'];
if (mode === 'online' || mode === 'do-not-disturb' || mode === 'offline') return mode;
if (entry.activity === 'sleep') return 'offline';
if (entry.activity === 'work' || entry.activity === 'study' || entry.activity === 'job-search' || entry.activity === 'travel' || entry.activity === 'commute') {
return 'do-not-disturb';
}
return 'online';
}
export function buildAvailabilitySnapshot(input: {
now: DateTimeInput;
generatedAt?: DateTimeInput;
entries: ScheduleEntry[];
}): ScheduledAvailabilitySnapshot {
const windowStart = startOfUtcDay(input.now);
const windowEnd = new Date(windowStart.getTime() + 2 * DAY_MS);
const sorted = input.entries
.filter((entry) => entry.startAt < windowEnd.toISOString() && entry.endAt > windowStart.toISOString())
.sort((a, b) => a.startAt.localeCompare(b.startAt));
const ranges: AvailabilityRange[] = [];
for (const entry of sorted) {
const mode = availabilityModeForEntry(entry);
const previous = ranges.at(-1);
if (previous && previous.mode === mode && previous.endAt === entry.startAt) {
previous.endAt = entry.endAt;
previous.sourceScheduleIds.push(entry.id);
continue;
}
ranges.push({
startAt: entry.startAt,
endAt: entry.endAt,
mode,
sourceScheduleIds: [entry.id],
reason: entry.title,
});
}
return {
generatedAt: toIso(input.generatedAt ?? input.now),
windowStartAt: windowStart.toISOString(),
windowEndAt: windowEnd.toISOString(),
ranges,
};
}
export function dateKeysAround(input: DateTimeInput): string[] {
const today = startOfUtcDay(input);
return [-1, 0, 1].map((offset) => new Date(today.getTime() + offset * DAY_MS).toISOString().slice(0, 10));
}

174
src/types.ts Normal file
View File

@@ -0,0 +1,174 @@
export type DateTimeInput = Date | string | number;
export type PersonaConstructorMode = 'create' | 'load';
export type ScheduleGranularity = 'day' | 'ten-minute';
export type ScheduleActivity =
| 'sleep'
| 'rest'
| 'meal'
| 'commute'
| 'work'
| 'study'
| 'job-search'
| 'travel'
| 'exercise'
| 'social'
| 'errand'
| 'free-time';
export type AvailabilityMode = 'online' | 'do-not-disturb' | 'offline';
export interface MemorySpace {
id: string;
displayName: string;
createdAt: string;
metadata: Record<string, unknown>;
}
export interface FactDraft {
statement: string;
topics: string[];
confidence?: number;
source?: string;
metadata?: Record<string, unknown>;
}
export interface StoredFact extends FactDraft {
id: string;
createdAt: string;
}
export interface ScheduleEntry {
id: string;
spaceId: string;
startAt: string;
endAt: string;
activity: ScheduleActivity;
title: string;
description?: string;
granularity: ScheduleGranularity;
sourceMessage?: string;
metadata: Record<string, unknown>;
}
export interface AvailabilityRange {
startAt: string;
endAt: string;
mode: AvailabilityMode;
sourceScheduleIds: string[];
reason: string;
}
export interface ScheduledAvailabilitySnapshot {
generatedAt: string;
windowStartAt: string;
windowEndAt: string;
ranges: AvailabilityRange[];
}
export interface PersonaMessage {
sender: 'persona' | 'user';
time: DateTimeInput;
content: string;
}
export interface DebugEvent {
name: string;
time: string;
spaceId?: string;
data?: Record<string, unknown>;
}
export type DebugHook = (event: DebugEvent) => void | Promise<void>;
export interface MandatoryConversationContext {
formattedMessageHistory: string;
conversationWindowLabel: string;
memorySummary: string;
personaAndUserFacts: StoredFact[];
scheduleEntries: ScheduleEntry[];
availability: ScheduledAvailabilitySnapshot;
}
export interface ReplyGenerationInput {
persona: MemorySpace;
now: string;
mode: 'reply' | 'start-conversation';
context: MandatoryConversationContext;
userMessage?: string;
instruction: string;
}
export interface OutgoingMessageDraft {
messages: string[];
reasoning?: string;
}
export interface RewriteDecisionInput {
persona: MemorySpace;
now: string;
previousHistory: PersonaMessage[];
latestHistory: PersonaMessage[];
draft: OutgoingMessageDraft;
context: MandatoryConversationContext;
}
export interface RewriteDecision {
rewrite: boolean;
draft?: OutgoingMessageDraft;
reason?: string;
}
export interface ConversationModel {
generateReply(input: ReplyGenerationInput): Promise<OutgoingMessageDraft>;
}
export interface RewriteModel {
decide(input: RewriteDecisionInput): Promise<RewriteDecision>;
}
export interface MemoryExtractionInput {
persona: MemorySpace;
now: string;
formattedMessageHistory: string;
contextFacts: StoredFact[];
instruction: string;
}
export interface MemoryExtractionModel {
extract(input: MemoryExtractionInput): Promise<FactDraft[]>;
}
export interface PersonaInitializationModel {
extractInitialFacts(input: {
displayName: string;
seedMessage: string;
now: string;
}): Promise<FactDraft[]>;
}
export interface PersonaModels {
initialization?: PersonaInitializationModel;
conversation?: ConversationModel;
rewrite?: RewriteModel;
memoryExtraction?: MemoryExtractionModel;
}
export interface PersonaOptions {
memory?: BoxBrainMemoryStore;
models?: PersonaModels;
debug?: DebugHook;
now?: DateTimeInput;
}
export interface BoxBrainMemoryStore {
createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace>;
getSpace(spaceId: string): Promise<MemorySpace | null>;
addFact(spaceId: string, fact: FactDraft): Promise<StoredFact>;
findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]>;
saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void>;
listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]>;
deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number>;
}