feat: implement schedule generation methods
This commit is contained in:
401
src/brain/index.test.ts
Normal file
401
src/brain/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
147
src/brain/schedule.test.ts
Normal 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
48
src/brain/schedule.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user