feat: implement schedule generation methods

This commit is contained in:
2026-06-05 23:59:53 +09:00
parent 9c510bb04d
commit 0c4231f159
4 changed files with 822 additions and 0 deletions

401
src/brain/index.test.ts Normal file
View File

@@ -0,0 +1,401 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import { randomUUID } from "node:crypto";
import { IdentityDB, type Space } from "identitydb";
const llmCalls: Array<{ model: unknown; options: any }> = [];
let customMonthlyDays: Array<{ day: number; summary: string }> | null = null;
let customDailySlots: Array<{
start: string;
end: string;
activity: string;
}> | null = null;
let customAvailability: Array<{
start: string;
end: string;
status: string;
}> | null = null;
function build48Slots(): Array<{
start: string;
end: string;
activity: string;
}> {
const slots: Array<{ start: string; end: string; activity: string }> = [];
for (let i = 0; i < 48; i++) {
const startHour = Math.floor(i / 2);
const startMin = (i % 2) * 30;
const start = `${String(startHour).padStart(2, "0")}:${String(startMin).padStart(2, "0")}`;
let end: string;
if (i === 47) {
end = "24:00";
} else {
const endHour = Math.floor((i + 1) / 2);
const endMin = ((i + 1) % 2) * 30;
end = `${String(endHour).padStart(2, "0")}:${String(endMin).padStart(2, "0")}`;
}
slots.push({ start, end, activity: `slot-${i}` });
}
return slots;
}
function build30Days(): Array<{ day: number; summary: string }> {
return Array.from({ length: 30 }, (_, i) => ({
day: i + 1,
summary: `Day ${i + 1} summary`,
}));
}
function buildAvailability(): Array<{
start: string;
end: string;
status: string;
}> {
return [
{ start: "00:00", end: "07:00", status: "offline" },
{ start: "07:00", end: "09:00", status: "online" },
{ start: "09:00", end: "17:00", status: "do-not-disturb" },
{ start: "17:00", end: "23:30", status: "online" },
{ start: "23:30", end: "24:00", status: "offline" },
];
}
const mockCall = mock(async <T>(model: unknown, options: any): Promise<T> => {
llmCalls.push({ model, options });
if (options.jsonSchemaName === "daily-schedule") {
return (customDailySlots ?? build48Slots()) as unknown as T;
}
if (options.jsonSchemaName === "monthly-schedule") {
if (customMonthlyDays) return customMonthlyDays as unknown as T;
const match = options.message.match(/\((\d+) days\)/);
const days = match ? parseInt(match[1]!, 10) : 30;
return Array.from({ length: days }, (_, i) => ({
day: i + 1,
summary: `Day ${i + 1} summary`,
})) as unknown as T;
}
if (options.jsonSchemaName === "availability") {
return (customAvailability ?? buildAvailability()) as unknown as T;
}
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
});
mock.module("@/openrouter", () => ({
llm: {
models: { conversation: "test-conv", identity: "test-id" },
call: mockCall,
},
}));
mock.module("@/config", () => ({
config: {
openrouterApiKey: "test-key",
dbPath: ":memory:",
braindbPath: "/tmp/brainbox-test-braindb.json",
},
}));
const { Brain } = await import("./index");
const { brainManager } = await import("./manager");
const { formatDateKey, nextDay, nextMonth } = await import("./schedule");
type BrainItem = import("./manager").BrainItem;
beforeAll(async () => {
try {
await brainManager.deleteBrain("smoke-test-id");
} catch {}
});
afterAll(async () => {});
async function makeBrain(): Promise<InstanceType<typeof Brain>> {
const db = await IdentityDB.connect({
client: "sqlite",
filename: ":memory:",
});
await db.initialize();
const spaceName = `test-space-${randomUUID()}`;
const space: Space = await db.upsertSpace({ name: spaceName });
const brainbase: BrainItem = {
brainId: randomUUID(),
spaceName,
displayName: "Test Brain",
baseSystemPrompt:
"Test personality: night owl, introverted, studies at midnight.",
};
return new Brain(db, space, brainbase);
}
beforeEach(() => {
llmCalls.length = 0;
customMonthlyDays = null;
customDailySlots = null;
customAvailability = null;
});
describe("Brain.createDailySchedule", () => {
test("S1: returns 48 slots in 30-min intervals and persists a fact", async () => {
const brain = await makeBrain();
const today = new Date(2026, 5, 5);
const expectedTomorrow = nextDay(today);
const expectedKey = formatDateKey(expectedTomorrow);
const result = await brain.createDailySchedule(today, "focus on writing");
expect(result).not.toBeNull();
expect(result).toHaveLength(48);
expect(result![0]).toEqual({
start: "00:00",
end: "00:30",
activity: "slot-0",
});
expect(result![47]).toEqual({
start: "23:30",
end: "24:00",
activity: "slot-47",
});
const llmCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "daily-schedule",
);
expect(llmCall).toBeDefined();
expect(llmCall!.options.message).toContain(expectedKey);
expect(llmCall!.options.message).toContain("focus on writing");
expect(llmCall!.options.message).toContain("Test personality");
const facts = await brain.db.getTopicFacts(
`daily-schedule:${expectedKey}`,
{
spaceName: brain.space.name,
},
);
expect(facts).toHaveLength(1);
expect(JSON.parse(facts[0]!.statement)).toHaveLength(48);
});
test("S4: month wrap (June 30 -> July 1)", async () => {
const brain = await makeBrain();
const today = new Date(2026, 5, 30);
const expectedKey = formatDateKey(new Date(2026, 6, 1));
await brain.createDailySchedule(today, "");
const facts = await brain.db.getTopicFacts(
`daily-schedule:${expectedKey}`,
{
spaceName: brain.space.name,
},
);
expect(facts).toHaveLength(1);
});
test("S4b: year wrap (December 31 -> January 1 next year)", async () => {
const brain = await makeBrain();
const today = new Date(2026, 11, 31);
const expectedKey = "2027-01-01";
await brain.createDailySchedule(today, "");
const facts = await brain.db.getTopicFacts(
`daily-schedule:${expectedKey}`,
{
spaceName: brain.space.name,
},
);
expect(facts).toHaveLength(1);
});
test("S6: consumes monthly summary for the target day when present", async () => {
const brain = await makeBrain();
customMonthlyDays = Array.from({ length: 30 }, (_, i) => ({
day: i + 1,
summary:
i + 1 === 10 ? "UNIQUE_SUMMARY_FOR_DAY_10" : `Day ${i + 1} summary`,
}));
const todayForMonthly = new Date(2026, 4, 15);
await brain.createMonthlySchedule(todayForMonthly, "");
const monthlyFacts = await brain.db.getTopicFacts(
`monthly-schedule:2026-06`,
{
spaceName: brain.space.name,
},
);
expect(monthlyFacts).toHaveLength(1);
llmCalls.length = 0;
customDailySlots = build48Slots();
const todayForDaily = new Date(2026, 5, 9);
await brain.createDailySchedule(todayForDaily, "");
const dailyLlmCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "daily-schedule",
);
expect(dailyLlmCall).toBeDefined();
expect(dailyLlmCall!.options.message).toContain(
"UNIQUE_SUMMARY_FOR_DAY_10",
);
});
});
describe("Brain.createMonthlySchedule", () => {
test("S2: returns N day summaries (N = days in next month) and persists a fact", async () => {
const brain = await makeBrain();
const today = new Date(2026, 0, 15);
const expected = nextMonth(today);
const expectedKey = `${expected.year}-${String(expected.month + 1).padStart(2, "0")}`;
const result = await brain.createMonthlySchedule(today, "study for GRE");
expect(result).not.toBeNull();
expect(result).toHaveLength(expected.daysInMonth);
expect(result![0]!.day).toBe(1);
expect(result![result!.length - 1]!.day).toBe(expected.daysInMonth);
const llmCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "monthly-schedule",
);
expect(llmCall).toBeDefined();
expect(llmCall!.options.message).toContain("study for GRE");
expect(llmCall!.options.message).toContain("Test personality");
const facts = await brain.db.getTopicFacts(
`monthly-schedule:${expectedKey}`,
{
spaceName: brain.space.name,
},
);
expect(facts).toHaveLength(1);
expect(JSON.parse(facts[0]!.statement)).toHaveLength(expected.daysInMonth);
});
test("S5: year wrap (December 15 -> January next year)", async () => {
const brain = await makeBrain();
const today = new Date(2026, 11, 15);
const expectedKey = "2027-01";
const result = await brain.createMonthlySchedule(today, "");
expect(result).not.toBeNull();
expect(result).toHaveLength(31);
const facts = await brain.db.getTopicFacts(
`monthly-schedule:${expectedKey}`,
{
spaceName: brain.space.name,
},
);
expect(facts).toHaveLength(1);
});
});
describe("Brain.getTodayScheduledAvailability", () => {
test("S3: returns availability windows when today's daily schedule exists", async () => {
const brain = await makeBrain();
const today = new Date(2026, 5, 10);
const todayKey = formatDateKey(today);
await brain.db.addFact({
spaceName: brain.space.name,
statement: JSON.stringify(build48Slots()),
summary: "test daily",
source: "test",
confidence: 1.0,
topics: [
{
name: `daily-schedule:${todayKey}`,
category: "temporal",
granularity: "concrete",
},
{
name: "daily-schedule",
category: "concept",
granularity: "abstract",
},
{ name: todayKey, category: "temporal", granularity: "concrete" },
],
});
const result = await brain.getTodayScheduledAvailability(today);
expect(result).not.toBeNull();
expect(result!.length).toBeGreaterThan(0);
for (const w of result!) {
expect(["online", "do-not-disturb", "offline"]).toContain(w.status);
expect(w.start).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$/);
expect(w.end).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$|^24:00$/);
}
const availabilityCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "availability",
);
expect(availabilityCall).toBeDefined();
});
test("returns null when no daily schedule exists for today", async () => {
const brain = await makeBrain();
const today = new Date(2026, 5, 10);
const result = await brain.getTodayScheduledAvailability(today);
expect(result).toBeNull();
expect(llmCalls).toHaveLength(0);
});
});
describe("Brain.removeScheduledAvailability", () => {
test("S7: cache invalidated after removeScheduledAvailability()", async () => {
const brain = await makeBrain();
const today = new Date(2026, 5, 10);
const todayKey = formatDateKey(today);
await brain.db.addFact({
spaceName: brain.space.name,
statement: JSON.stringify(build48Slots()),
summary: "test daily",
source: "test",
confidence: 1.0,
topics: [
{
name: `daily-schedule:${todayKey}`,
category: "temporal",
granularity: "concrete",
},
{
name: "daily-schedule",
category: "concept",
granularity: "abstract",
},
{ name: todayKey, category: "temporal", granularity: "concrete" },
],
});
const r1 = await brain.getTodayScheduledAvailability(today);
expect(r1).not.toBeNull();
const callCountAfterFirst = llmCalls.length;
expect(callCountAfterFirst).toBe(1);
const r2 = await brain.getTodayScheduledAvailability(today);
expect(r2).not.toBeNull();
expect(llmCalls.length).toBe(callCountAfterFirst);
brain.removeScheduledAvailability();
const r3 = await brain.getTodayScheduledAvailability(today);
expect(r3).not.toBeNull();
expect(llmCalls.length).toBe(callCountAfterFirst + 1);
});
});
describe("S8: regression on existing methods", () => {
test("Brain.create and Brain.load are still defined as static methods", () => {
expect(typeof Brain.create).toBe("function");
expect(typeof Brain.load).toBe("function");
});
});

View File

@@ -3,17 +3,243 @@ import { config } from "@/config";
import { IdentityDB, type Space } from "identitydb";
import { llm } from "@/openrouter";
import { loadPrompt } from "@/openrouter/promptLoader";
import {
availabilitySchema,
dailyScheduleSchema,
monthlyScheduleSchema,
} from "@/openrouter/schema";
import { logger } from "@/utils/logger";
import { factExtractor } from "./factExtractor";
import { brainManager, type BrainItem } from "./manager";
import {
type Availability,
type DailySchedule,
type MonthlySchedule,
formatDateKey,
formatMonthKey,
nextDay,
nextMonth,
pad2,
} from "./schedule";
export class Brain {
private availabilityCache: Map<string, Availability[]> = new Map();
constructor(
public db: IdentityDB,
public space: Space,
public brainbase: BrainItem,
) {}
async createDailySchedule(
datetime: Date,
message: string,
): Promise<DailySchedule | null> {
try {
const target = nextDay(datetime);
const dateKey = formatDateKey(target);
const topicName = `daily-schedule:${dateKey}`;
const monthlySummary = await this.getMonthlySummaryForDay(target);
const history = await this.getHistoryFacts();
const instruction = await loadPrompt("DAILY_SCHEDULE");
const promptMessage = [
`Target date: ${dateKey} (${target.toLocaleDateString("en-US", { weekday: "long" })})`,
`Personality: ${this.brainbase.baseSystemPrompt}`,
monthlySummary
? `Monthly summary for this day: ${monthlySummary}`
: "(no monthly summary available for this date)",
`Recent history (facts):`,
history,
`User direction: ${message}`,
].join("\n\n");
const schedule = await llm.call<DailySchedule>(llm.models.identity, {
instruction,
message: promptMessage,
jsonSchemaName: "daily-schedule",
jsonSchema: dailyScheduleSchema,
});
await this.db.addFact({
spaceName: this.space.name,
statement: JSON.stringify(schedule),
summary: `Daily schedule for ${dateKey} (${schedule.length} slots)`,
source: "createDailySchedule",
confidence: 1.0,
topics: [
{
name: topicName,
category: "temporal",
granularity: "concrete",
role: "schedule",
},
{
name: "daily-schedule",
category: "concept",
granularity: "abstract",
role: "schedule",
},
{
name: dateKey,
category: "temporal",
granularity: "concrete",
role: "date",
},
],
});
return schedule;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logger.error(`createDailySchedule failed: ${reason}`);
return null;
}
}
async createMonthlySchedule(
datetime: Date,
message: string,
): Promise<MonthlySchedule | null> {
try {
const next = nextMonth(datetime);
const monthKey = `${next.year}-${pad2(next.month + 1)}`;
const topicName = `monthly-schedule:${monthKey}`;
const history = await this.getHistoryFacts();
const instruction = await loadPrompt("MONTHLY_SCHEDULE");
const promptMessage = [
`Target month: ${monthKey} (${next.daysInMonth} days)`,
`Personality: ${this.brainbase.baseSystemPrompt}`,
`Recent history (facts):`,
history,
`User direction: ${message}`,
].join("\n\n");
const schedule = await llm.call<MonthlySchedule>(llm.models.identity, {
instruction,
message: promptMessage,
jsonSchemaName: "monthly-schedule",
jsonSchema: monthlyScheduleSchema,
});
await this.db.addFact({
spaceName: this.space.name,
statement: JSON.stringify(schedule),
summary: `Monthly schedule for ${monthKey} (${schedule.length} days)`,
source: "createMonthlySchedule",
confidence: 1.0,
topics: [
{
name: topicName,
category: "temporal",
granularity: "concrete",
role: "schedule",
},
{
name: "monthly-schedule",
category: "concept",
granularity: "abstract",
role: "schedule",
},
{
name: monthKey,
category: "temporal",
granularity: "concrete",
role: "period",
},
],
});
return schedule;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logger.error(`createMonthlySchedule failed: ${reason}`);
return null;
}
}
async getTodayScheduledAvailability(
datetime: Date,
): Promise<Availability[] | null> {
try {
const dateKey = formatDateKey(datetime);
const cached = this.availabilityCache.get(dateKey);
if (cached) return cached;
const topicName = `daily-schedule:${dateKey}`;
const facts = await this.db.getTopicFacts(topicName, {
spaceName: this.space.name,
});
if (facts.length === 0) return null;
const dailySchedule = JSON.parse(facts[0]!.statement) as DailySchedule;
const instruction = await loadPrompt("SCHEDULE_AVAILABILITY");
const promptMessage = JSON.stringify({
schedule: dailySchedule,
personality: this.brainbase.baseSystemPrompt,
});
const availability = await llm.call<Availability[]>(llm.models.identity, {
instruction,
message: promptMessage,
jsonSchemaName: "availability",
jsonSchema: availabilitySchema,
});
this.availabilityCache.set(dateKey, availability);
return availability;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logger.error(`getTodayScheduledAvailability failed: ${reason}`);
return null;
}
}
removeScheduledAvailability(): void {
this.availabilityCache.clear();
}
private async getMonthlySummaryForDay(target: Date): Promise<string | null> {
try {
const monthKey = formatMonthKey(target);
const topicName = `monthly-schedule:${monthKey}`;
const facts = await this.db.getTopicFacts(topicName, {
spaceName: this.space.name,
});
if (facts.length === 0) return null;
const monthly = JSON.parse(facts[0]!.statement) as MonthlySchedule;
const day = target.getDate();
const entry = monthly.find((d) => d.day === day);
return entry?.summary ?? null;
} catch {
return null;
}
}
private async getHistoryFacts(): Promise<string> {
try {
const topics = await this.db.listTopics({
spaceName: this.space.name,
includeFacts: true,
});
const statements: string[] = [];
for (const topic of topics) {
const t = topic as { facts?: Array<{ statement: string }> };
if (t.facts) {
for (const f of t.facts) statements.push(f.statement);
}
}
return statements.slice(-30).join("\n");
} catch {
return "";
}
}
static async create(
displayName: string,
seed: string,

147
src/brain/schedule.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import {
formatDateKey,
formatMonthKey,
nextDay,
nextMonth,
pad2,
} from "./schedule";
describe("pad2", () => {
test("zero-pads single digit", () => {
expect(pad2(0)).toBe("00");
expect(pad2(1)).toBe("01");
expect(pad2(9)).toBe("09");
});
test("does not pad two digits", () => {
expect(pad2(10)).toBe("10");
expect(pad2(31)).toBe("31");
expect(pad2(99)).toBe("99");
});
});
describe("formatDateKey", () => {
test("formats YYYY-MM-DD with zero padding", () => {
expect(formatDateKey(new Date(2026, 0, 5))).toBe("2026-01-05");
expect(formatDateKey(new Date(2026, 11, 31))).toBe("2026-12-31");
expect(formatDateKey(new Date(2026, 5, 30))).toBe("2026-06-30");
});
test("handles months 1-9 with zero padding", () => {
expect(formatDateKey(new Date(2026, 8, 9))).toBe("2026-09-09");
});
});
describe("formatMonthKey", () => {
test("formats YYYY-MM with zero padding", () => {
expect(formatMonthKey(new Date(2026, 0, 1))).toBe("2026-01");
expect(formatMonthKey(new Date(2026, 11, 15))).toBe("2026-12");
});
test("handles month 9 with zero padding", () => {
expect(formatMonthKey(new Date(2026, 8, 30))).toBe("2026-09");
});
});
describe("nextDay", () => {
test("returns next day within the same month", () => {
const d = new Date(2026, 5, 5);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2026);
expect(n.getMonth()).toBe(5);
expect(n.getDate()).toBe(6);
});
test("wraps month on last day", () => {
const d = new Date(2026, 5, 30);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2026);
expect(n.getMonth()).toBe(6);
expect(n.getDate()).toBe(1);
});
test("wraps year on December 31", () => {
const d = new Date(2026, 11, 31);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2027);
expect(n.getMonth()).toBe(0);
expect(n.getDate()).toBe(1);
});
test("DST-safe: returns midnight in local time after US spring-forward", () => {
// US DST 2026 starts March 8, 2026
const d = new Date(2026, 2, 8);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2026);
expect(n.getMonth()).toBe(2);
expect(n.getDate()).toBe(9);
expect(n.getHours()).toBe(0);
expect(n.getMinutes()).toBe(0);
});
test("DST-safe: returns midnight in local time after US fall-back", () => {
// US DST 2026 ends November 1, 2026
const d = new Date(2026, 10, 1);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2026);
expect(n.getMonth()).toBe(10);
expect(n.getDate()).toBe(2);
expect(n.getHours()).toBe(0);
expect(n.getMinutes()).toBe(0);
});
test("handles February in leap year", () => {
const d = new Date(2024, 1, 28);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2024);
expect(n.getMonth()).toBe(1);
expect(n.getDate()).toBe(29);
});
test("handles February in non-leap year", () => {
const d = new Date(2026, 1, 28);
const n = nextDay(d);
expect(n.getFullYear()).toBe(2026);
expect(n.getMonth()).toBe(2);
expect(n.getDate()).toBe(1);
});
test("does not mutate input date", () => {
const d = new Date(2026, 5, 15);
const originalTime = d.getTime();
nextDay(d);
expect(d.getTime()).toBe(originalTime);
});
});
describe("nextMonth", () => {
test("returns next month within the same year", () => {
const r = nextMonth(new Date(2026, 0, 15));
expect(r.year).toBe(2026);
expect(r.month).toBe(1);
expect(r.daysInMonth).toBe(28);
});
test("returns daysInMonth for 30-day months", () => {
expect(nextMonth(new Date(2026, 2, 15)).daysInMonth).toBe(30); // April
expect(nextMonth(new Date(2026, 3, 15)).daysInMonth).toBe(31); // May
});
test("returns 29 for February in a leap year", () => {
const r = nextMonth(new Date(2024, 0, 15));
expect(r.year).toBe(2024);
expect(r.month).toBe(1);
expect(r.daysInMonth).toBe(29);
});
test("returns 28 for February in a non-leap year", () => {
const r = nextMonth(new Date(2026, 0, 15));
expect(r.year).toBe(2026);
expect(r.month).toBe(1);
expect(r.daysInMonth).toBe(28);
});
test("returns 31 for January", () => {
const r = nextMonth(new Date(2025, 11, 15));
expect(r.year).toBe(2026);
expect(r.month).toBe(0);
expect(r.daysInMonth).toBe(31);
});
test("wraps year on December 15", () => {
const r = nextMonth(new Date(2026, 11, 15));
expect(r.year).toBe(2027);
expect(r.month).toBe(0);
expect(r.daysInMonth).toBe(31);
});
test("month is zero-indexed (0 = January)", () => {
const r = nextMonth(new Date(2026, 6, 15));
expect(r.month).toBe(7);
});
});

48
src/brain/schedule.ts Normal file
View File

@@ -0,0 +1,48 @@
export type DailySlot = {
start: string;
end: string;
activity: string;
notes?: string;
};
export type DailySchedule = DailySlot[];
export type MonthlyDay = {
day: number;
summary: string;
};
export type MonthlySchedule = MonthlyDay[];
export type AvailabilityStatus = "online" | "do-not-disturb" | "offline";
export type Availability = {
start: string;
end: string;
status: AvailabilityStatus;
};
export type AvailabilityWindows = Availability[];
export function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
export function formatDateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function formatMonthKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
}
export function nextDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
}
export function nextMonth(d: Date): {
year: number;
month: number;
daysInMonth: number;
} {
const year = d.getMonth() === 11 ? d.getFullYear() + 1 : d.getFullYear();
const month = (d.getMonth() + 1) % 12;
const daysInMonth = new Date(year, month + 1, 0).getDate();
return { year, month, daysInMonth };
}