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

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