diff --git a/src/brain/factExtractor.ts b/src/brain/factExtractor.ts index 2de8436..9967aa0 100644 --- a/src/brain/factExtractor.ts +++ b/src/brain/factExtractor.ts @@ -1,16 +1,17 @@ import { llm } from "@/openrouter"; -import { extractedFactSchema } from "@/openrouter/schema"; +import { extractedFactSchema, type ExtractedFactResult } from "@/openrouter/schema"; import { type ExtractedFact, LlmFactExtractor } from "identitydb"; export const factExtractor = new LlmFactExtractor({ model: { async generateText({ instruction, input }) { - return await llm.call(llm.models.identity, { + const result = await llm.call(llm.models.identity, { instruction, message: input, jsonSchemaName: "fact-extractor", jsonSchema: extractedFactSchema, }); + return result.items; }, }, }); diff --git a/src/brain/index.test.ts b/src/brain/index.test.ts index c91e21d..bd0e729 100644 --- a/src/brain/index.test.ts +++ b/src/brain/index.test.ts @@ -80,16 +80,20 @@ const mockCall = mock(async (model: unknown, options: any): Promise => { return { items: customDailySlots ?? build48Slots() } as unknown as T; } if (options.jsonSchemaName === "monthly-schedule") { - if (customMonthlyDays) return customMonthlyDays as unknown as T; + if (customMonthlyDays) { + return { items: 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; + return { + items: 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; + return { items: customAvailability ?? buildAvailability() } as unknown as T; } throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`); }); @@ -267,9 +271,11 @@ describe("Brain.createMonthlySchedule", () => { 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); + expect(result!.items).toHaveLength(expected.daysInMonth); + expect(result!.items[0]!.day).toBe(1); + expect(result!.items[result!.items.length - 1]!.day).toBe( + expected.daysInMonth, + ); const llmCall = llmCalls.find( (c) => c.options.jsonSchemaName === "monthly-schedule", @@ -285,7 +291,9 @@ describe("Brain.createMonthlySchedule", () => { }, ); expect(facts).toHaveLength(1); - expect(JSON.parse(facts[0]!.statement)).toHaveLength(expected.daysInMonth); + expect(JSON.parse(facts[0]!.statement).items).toHaveLength( + expected.daysInMonth, + ); }); test("S5: year wrap (December 15 -> January next year)", async () => { @@ -296,7 +304,7 @@ describe("Brain.createMonthlySchedule", () => { const result = await brain.createMonthlySchedule(today, ""); expect(result).not.toBeNull(); - expect(result).toHaveLength(31); + expect(result!.items).toHaveLength(31); const facts = await brain.db.getTopicFacts( `monthly-schedule:${expectedKey}`, @@ -337,8 +345,8 @@ describe("Brain.getTodayScheduledAvailability", () => { const result = await brain.getTodayScheduledAvailability(today); expect(result).not.toBeNull(); - expect(result!.length).toBeGreaterThan(0); - for (const w of result!) { + expect(result!.items.length).toBeGreaterThan(0); + for (const w of result!.items) { 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$/); @@ -451,7 +459,7 @@ describe("Brain.createDebug", () => { const schedule = await brain.createMonthlySchedule(today, "msg"); expect(schedule).not.toBeNull(); - expect(schedule).toHaveLength(expected.daysInMonth); + expect(schedule!.items).toHaveLength(expected.daysInMonth); const facts = await brain.db.getTopicFacts( `monthly-schedule:${monthKey}`, diff --git a/src/brain/index.ts b/src/brain/index.ts index 4d25c24..b15b9bb 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -8,6 +8,7 @@ import { dailyScheduleSchema, monthlyScheduleSchema, type Availability, + type AvailabilityWindows, type DailySchedule, type MonthlySchedule, } from "@/openrouter/schema"; @@ -28,7 +29,7 @@ export interface DebugOptions { } export class Brain { - private availabilityCache: Map = new Map(); + private availabilityCache: Map = new Map(); constructor( public db: IdentityDB, @@ -142,7 +143,7 @@ export class Brain { await this.db.addFact({ spaceName: this.space.name, statement: JSON.stringify(schedule), - summary: `Monthly schedule for ${monthKey} (${schedule.length} days)`, + summary: `Monthly schedule for ${monthKey} (${schedule.items.length} days)`, source: "createMonthlySchedule", confidence: 1.0, topics: [ @@ -178,7 +179,7 @@ export class Brain { async getTodayScheduledAvailability( datetime: Date, - ): Promise { + ): Promise { try { const dateKey = formatDateKey(datetime); const cached = this.availabilityCache.get(dateKey); @@ -212,7 +213,7 @@ export class Brain { async deriveAvailabilityFromSchedule( schedule: DailySchedule, - ): Promise { + ): Promise { try { const instruction = await loadPrompt("SCHEDULE_AVAILABILITY"); const promptMessage = JSON.stringify({ @@ -220,7 +221,7 @@ export class Brain { personality: this.brainbase.baseSystemPrompt, }); - return await llm.call(llm.models.identity, { + return await llm.call(llm.models.identity, { instruction, message: promptMessage, jsonSchemaName: "availability", @@ -249,7 +250,7 @@ export class Brain { const monthly = JSON.parse(facts[0]!.statement) as MonthlySchedule; const day = target.getDate(); - const entry = monthly.find((d) => d.day === day); + const entry = monthly.items.find((d) => d.day === day); return entry?.summary ?? null; } catch { return null; diff --git a/src/commands/debug/brain.test.ts b/src/commands/debug/brain.test.ts index 04a2a23..6e3aae0 100644 --- a/src/commands/debug/brain.test.ts +++ b/src/commands/debug/brain.test.ts @@ -73,7 +73,7 @@ const mockCall = mock(async (model: unknown, options: any): Promise => { return GENERATED_BASE_SYSTEM_PROMPT as unknown as T; } if (options.jsonSchemaName === "fact-extractor") { - return EXTRACTED_FACTS as unknown as T; + return { items: EXTRACTED_FACTS } as unknown as T; } throw new Error( `unexpected LLM call: model=${model} instruction=${options.instruction?.slice(0, 80)}`, diff --git a/src/commands/debug/schedule.test.ts b/src/commands/debug/schedule.test.ts index 3279286..e5df047 100644 --- a/src/commands/debug/schedule.test.ts +++ b/src/commands/debug/schedule.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { Availability, DailySlot, - MonthlySchedule, + MonthlyDay, } from "@/openrouter/schema"; interface RecordedCall { @@ -39,7 +39,7 @@ function buildAvailability(): Availability[] { ]; } -function buildMonthly(): MonthlySchedule { +function buildMonthlyDays(): MonthlyDay[] { return Array.from({ length: 30 }, (_, i) => ({ day: i + 1, summary: `Day ${i + 1} summary`, @@ -53,12 +53,15 @@ const mockCall = mock(async (_model: unknown, options: any) => { if (options.jsonSchemaName === "monthly-schedule") { const match = (options.message as string).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`, - })); + return { + items: Array.from({ length: days }, (_, i) => ({ + day: i + 1, + summary: `Day ${i + 1} summary`, + })), + }; } - if (options.jsonSchemaName === "availability") return buildAvailability(); + if (options.jsonSchemaName === "availability") + return { items: buildAvailability() }; throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`); }); @@ -87,7 +90,6 @@ beforeEach(() => { }); afterEach(async () => { - // ensure no on-disk braindb was created const { unlink } = await import("fs/promises"); try { await unlink("/tmp/brainbox-test-braindb-debug-schedule.json"); @@ -106,7 +108,7 @@ describe("runDebugScheduleDaily", () => { expect(result.kind).toBe("daily"); expect(result.schedule.items).toHaveLength(48); - expect(result.availability.length).toBeGreaterThan(0); + expect(result.availability.items.length).toBeGreaterThan(0); expect(result.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); const dailyCall = llmCalls.find( @@ -145,7 +147,7 @@ describe("runDebugScheduleMonthly", () => { if (!result.ok) throw new Error("expected ok"); expect(result.kind).toBe("monthly"); - expect(result.schedule).toHaveLength(result.daysInMonth); + expect(result.schedule.items).toHaveLength(result.daysInMonth); expect(result.monthKey).toMatch(/^\d{4}-\d{2}$/); const call = llmCalls.find( @@ -187,3 +189,4 @@ describe("debug schedule no-disk invariant", () => { expect(afterJson).toBe(false); }); }); + diff --git a/src/commands/debug/schedule.ts b/src/commands/debug/schedule.ts index d982445..f7f1be6 100644 --- a/src/commands/debug/schedule.ts +++ b/src/commands/debug/schedule.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import ora from "ora"; import { Brain } from "@/brain"; import { - type Availability, + type AvailabilityWindows, type DailySchedule, type MonthlySchedule, } from "@/openrouter/schema"; @@ -21,7 +21,7 @@ export type DailyRunResult = dateKey: string; tomorrow: Date; schedule: DailySchedule; - availability: Availability[]; + availability: AvailabilityWindows; } | { ok: false; error: string }; @@ -72,7 +72,7 @@ export async function runDebugScheduleDaily( return { ok: false, error: "Availability derivation failed" }; } availSpinner.succeed( - `Availability derived (${availability.length} windows)`, + `Availability derived (${availability.items.length} windows)`, ); printSection(`Availability — ${dateKey}`); @@ -108,7 +108,7 @@ export async function runDebugScheduleMonthly( return { ok: false, error: "Monthly schedule generation failed" }; } scheduleSpinner.succeed( - `Monthly schedule generated (${schedule.length} day summaries)`, + `Monthly schedule generated (${schedule.items.length} day summaries)`, ); printSection(`Monthly Schedule — ${monthKey} (${next.daysInMonth} days)`); diff --git a/src/openrouter/schema.ts b/src/openrouter/schema.ts index 553530b..addcab2 100644 --- a/src/openrouter/schema.ts +++ b/src/openrouter/schema.ts @@ -133,6 +133,8 @@ export const availabilitySchema = { // Types — co-located with their schemas. // ---------------------------------------------------------------------------- +import type { ExtractedFact } from "identitydb"; + /** A single 30-minute slot in a daily schedule. Matches `dailyScheduleSchema.items.items`. */ export type DailySlot = { start: string; @@ -150,24 +152,43 @@ export type DailySchedule = { items: DailySlot[]; }; -/** A single day's summary inside a monthly schedule. Matches `monthlyScheduleSchema.items`. */ +/** A single day's summary inside a monthly schedule. Matches `monthlyScheduleSchema.items.items`. */ export type MonthlyDay = { day: number; summary: string; }; -/** A complete monthly schedule. Matches `monthlyScheduleSchema`. */ -export type MonthlySchedule = MonthlyDay[]; +/** + * A complete monthly schedule: a wrapped object containing one entry per day + * of the month. Matches `monthlyScheduleSchema`. + */ +export type MonthlySchedule = { + items: MonthlyDay[]; +}; /** Reachability status for a single availability window. */ export type AvailabilityStatus = "online" | "do-not-disturb" | "offline"; -/** A single availability window. Matches `availabilitySchema.items`. */ +/** A single availability window. Matches `availabilitySchema.items.items`. */ export type Availability = { start: string; end: string; status: AvailabilityStatus; }; -/** The full set of availability windows for a day. Matches `availabilitySchema`. */ -export type AvailabilityWindows = Availability[]; +/** + * The full set of availability windows for a day: a wrapped object containing + * one or more windows. Matches `availabilitySchema`. + */ +export type AvailabilityWindows = { + items: Availability[]; +}; + +/** + * The wrapped envelope the LLM returns for `extractedFactSchema`. The inner + * `items` array is the list of `ExtractedFact` (which is defined in the + * external `identitydb` package). + */ +export type ExtractedFactResult = { + items: ExtractedFact[]; +};