diff --git a/src/brain/index.test.ts b/src/brain/index.test.ts index f2b69e2..0bddd30 100644 --- a/src/brain/index.test.ts +++ b/src/brain/index.test.ts @@ -399,3 +399,55 @@ describe("S8: regression on existing methods", () => { expect(typeof Brain.load).toBe("function"); }); }); + +describe("Brain.createDebug", () => { + test("D1: returns a Brain with debug=true, the supplied personality, and no disk file created", async () => { + const { existsSync } = await import("fs"); + const { resolve } = await import("path"); + + const before = existsSync(resolve(process.cwd(), "brainbox.db")); + + const brain = await Brain.createDebug({ personality: "test-personality-Q" }); + + expect(brain).toBeInstanceOf(Brain); + expect(brain.debug).toBe(true); + expect(brain.brainbase.baseSystemPrompt).toBe("test-personality-Q"); + expect(brain.brainbase.displayName).toBe("Debug Brain"); + + const after = existsSync(resolve(process.cwd(), "brainbox.db")); + expect(after).toBe(before); + }); + + test("D2: createDailySchedule on a debug brain returns a schedule and does NOT add a fact to the DB", async () => { + const brain = await Brain.createDebug({ personality: "p" }); + const today = new Date(2026, 5, 5); + const tomorrow = new Date(2026, 5, 6); + const tomorrowKey = formatDateKey(tomorrow); + + const schedule = await brain.createDailySchedule(today, "msg"); + expect(schedule).not.toBeNull(); + expect(schedule).toHaveLength(48); + + const facts = await brain.db.getTopicFacts(`daily-schedule:${tomorrowKey}`, { + spaceName: brain.space.name, + }); + expect(facts).toHaveLength(0); + }); + + test("D3: createMonthlySchedule on a debug brain returns a schedule and does NOT add a fact to the DB", async () => { + const brain = await Brain.createDebug({ personality: "p" }); + const today = new Date(2026, 0, 15); + const expected = nextMonth(today); + const monthKey = `${expected.year}-${String(expected.month + 1).padStart(2, "0")}`; + + const schedule = await brain.createMonthlySchedule(today, "msg"); + expect(schedule).not.toBeNull(); + expect(schedule).toHaveLength(expected.daysInMonth); + + const facts = await brain.db.getTopicFacts( + `monthly-schedule:${monthKey}`, + { spaceName: brain.space.name }, + ); + expect(facts).toHaveLength(0); + }); +}); diff --git a/src/brain/index.ts b/src/brain/index.ts index 0adc47a..658e157 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -21,6 +21,11 @@ import { nextMonth, pad2, } from "./schedule"; +import { BadRequestResponseError } from "@openrouter/sdk/models/errors"; + +export interface DebugOptions { + personality: string; +} export class Brain { private availabilityCache: Map = new Map(); @@ -29,6 +34,7 @@ export class Brain { public db: IdentityDB, public space: Space, public brainbase: BrainItem, + public debug: boolean = false, ) {} async createDailySchedule( @@ -62,37 +68,44 @@ export class Brain { 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", - }, - ], - }); + if (!this.debug) { + 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); + let reason = + error instanceof Error + ? error.message + `(${error.name})` + : String(error); + if (error instanceof BadRequestResponseError) + reason = reason + `${error.body}`; logger.error(`createDailySchedule failed: ${reason}`); return null; } @@ -125,33 +138,35 @@ export class Brain { 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", - }, - ], - }); + if (!this.debug) { + 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) { @@ -169,6 +184,13 @@ export class Brain { const cached = this.availabilityCache.get(dateKey); if (cached) return cached; + if (this.debug) { + logger.warn( + "getTodayScheduledAvailability requires a persisted daily schedule; debug brains have no DB. Use deriveAvailabilityFromSchedule(schedule) instead.", + ); + return null; + } + const topicName = `daily-schedule:${dateKey}`; const facts = await this.db.getTopicFacts(topicName, { spaceName: this.space.name, @@ -176,19 +198,8 @@ export class Brain { 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(llm.models.identity, { - instruction, - message: promptMessage, - jsonSchemaName: "availability", - jsonSchema: availabilitySchema, - }); + const availability = + await this.deriveAvailabilityFromSchedule(dailySchedule); this.availabilityCache.set(dateKey, availability); return availability; @@ -199,11 +210,35 @@ export class Brain { } } + async deriveAvailabilityFromSchedule( + schedule: DailySchedule, + ): Promise { + try { + const instruction = await loadPrompt("SCHEDULE_AVAILABILITY"); + const promptMessage = JSON.stringify({ + schedule, + personality: this.brainbase.baseSystemPrompt, + }); + + return await llm.call(llm.models.identity, { + instruction, + message: promptMessage, + jsonSchemaName: "availability", + jsonSchema: availabilitySchema, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`deriveAvailabilityFromSchedule failed: ${reason}`); + throw error; + } + } + removeScheduledAvailability(): void { this.availabilityCache.clear(); } private async getMonthlySummaryForDay(target: Date): Promise { + if (this.debug) return null; try { const monthKey = formatMonthKey(target); const topicName = `monthly-schedule:${monthKey}`; @@ -222,6 +257,7 @@ export class Brain { } private async getHistoryFacts(): Promise { + if (this.debug) return ""; try { const topics = await this.db.listTopics({ spaceName: this.space.name, @@ -306,4 +342,25 @@ export class Brain { return new Brain(db, space, brain); } + + static async createDebug(options: DebugOptions): Promise { + const db = await IdentityDB.connect({ + client: "sqlite", + filename: ":memory:", + }); + await db.initialize(); + const space = await db.upsertSpace({ + name: "debug", + description: "Debug Brain", + }); + + const brainbase: BrainItem = { + brainId: "debug", + spaceName: "debug", + displayName: "Debug Brain", + baseSystemPrompt: options.personality, + }; + + return new Brain(db, space, brainbase, true); + } } diff --git a/src/commands/brain.ts b/src/commands/brain.ts index ac84bd0..1a8d68a 100644 --- a/src/commands/brain.ts +++ b/src/commands/brain.ts @@ -1,2 +1,11 @@ -// Manage brains +import type { Command } from "commander"; +import { registerCommand } from "@/commands"; + export async function brain() {} + +export function register(program: Command): Command { + return registerCommand(program, { + name: "brain", + description: "Manage brains", + }).action(brain); +} diff --git a/src/commands/debug/index.ts b/src/commands/debug/index.ts new file mode 100644 index 0000000..f04df16 --- /dev/null +++ b/src/commands/debug/index.ts @@ -0,0 +1,12 @@ +import type { Command } from "commander"; +import { registerCommand } from "@/commands"; +import { addScheduleSubcommand } from "./schedule"; + +export function register(program: Command): Command { + return registerCommand(program, { + name: "debug", + description: + "Dry-run tools: exercise code paths without writing to the database or braindb", + configure: addScheduleSubcommand, + }); +} diff --git a/src/commands/debug/schedule.test.ts b/src/commands/debug/schedule.test.ts new file mode 100644 index 0000000..b89a369 --- /dev/null +++ b/src/commands/debug/schedule.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { Availability, DailySchedule, MonthlySchedule } from "@/brain/schedule"; + +interface RecordedCall { + model: unknown; + options: { jsonSchemaName?: string; message?: string }; +} + +const llmCalls: RecordedCall[] = []; + +function build48Slots(): DailySchedule { + const slots: DailySchedule = []; + 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 buildAvailability(): Availability[] { + return [ + { start: "00:00", end: "07:00", status: "offline" }, + { start: "07:00", end: "23:30", status: "online" }, + { start: "23:30", end: "24:00", status: "offline" }, + ]; +} + +function buildMonthly(): MonthlySchedule { + return Array.from({ length: 30 }, (_, i) => ({ + day: i + 1, + summary: `Day ${i + 1} summary`, + })); +} + +const mockCall = mock(async (_model: unknown, options: any) => { + llmCalls.push({ model: _model, options }); + if (options.jsonSchemaName === "daily-schedule") return build48Slots(); + 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`, + })); + } + if (options.jsonSchemaName === "availability") return buildAvailability(); + 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-debug-schedule.json", + }, +})); + +const { runDebugScheduleDaily, runDebugScheduleMonthly } = await import( + "./schedule" +); + +beforeEach(() => { + llmCalls.length = 0; + mockCall.mockClear(); +}); + +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"); + } catch {} +}); + +describe("runDebugScheduleDaily", () => { + test("T1: returns ok result with schedule and availability, uses the supplied personality", async () => { + const result = await runDebugScheduleDaily({ + message: "focus on writing", + personality: "test-personality-XYZ", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.kind).toBe("daily"); + expect(result.schedule).toHaveLength(48); + expect(result.availability.length).toBeGreaterThan(0); + expect(result.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + const dailyCall = llmCalls.find( + (c) => c.options.jsonSchemaName === "daily-schedule", + ); + expect(dailyCall).toBeDefined(); + expect(dailyCall!.options.message).toContain("test-personality-XYZ"); + expect(dailyCall!.options.message).toContain("focus on writing"); + + const availCall = llmCalls.find( + (c) => c.options.jsonSchemaName === "availability", + ); + expect(availCall).toBeDefined(); + }); + + test("T2: when LLM returns null for daily, returns {ok: false, error}", async () => { + mockCall.mockImplementationOnce(async () => null as unknown as never); + const result = await runDebugScheduleDaily({ + message: "", + personality: "p", + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected !ok"); + expect(result.error).toMatch(/Daily schedule generation failed/); + }); +}); + +describe("runDebugScheduleMonthly", () => { + test("T3: returns ok result with monthly schedule, uses the supplied personality", async () => { + const result = await runDebugScheduleMonthly({ + message: "study for GRE", + personality: "test-personality-ABC", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.kind).toBe("monthly"); + expect(result.schedule).toHaveLength(result.daysInMonth); + expect(result.monthKey).toMatch(/^\d{4}-\d{2}$/); + + const call = llmCalls.find( + (c) => c.options.jsonSchemaName === "monthly-schedule", + ); + expect(call).toBeDefined(); + expect(call!.options.message).toContain("test-personality-ABC"); + expect(call!.options.message).toContain("study for GRE"); + }); + + test("T4: when LLM returns null for monthly, returns {ok: false, error}", async () => { + mockCall.mockImplementationOnce(async () => null as unknown as never); + const result = await runDebugScheduleMonthly({ + message: "", + personality: "p", + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected !ok"); + expect(result.error).toMatch(/Monthly schedule generation failed/); + }); +}); + +describe("debug schedule no-disk invariant", () => { + test("T5: running a debug schedule does not create a brainbox db file on disk", async () => { + const { existsSync } = await import("fs"); + const { resolve } = await import("path"); + + const beforeDb = existsSync(resolve(process.cwd(), "brainbox.db")); + const beforeJson = existsSync(resolve(process.cwd(), "brainbox.json")); + + await runDebugScheduleDaily({ message: "m", personality: "p" }); + + const afterDb = existsSync(resolve(process.cwd(), "brainbox.db")); + const afterJson = existsSync(resolve(process.cwd(), "brainbox.json")); + + expect(beforeDb).toBe(false); + expect(beforeJson).toBe(false); + expect(afterDb).toBe(false); + expect(afterJson).toBe(false); + }); +}); diff --git a/src/commands/debug/schedule.ts b/src/commands/debug/schedule.ts new file mode 100644 index 0000000..a5f7d8d --- /dev/null +++ b/src/commands/debug/schedule.ts @@ -0,0 +1,171 @@ +import type { Command } from "commander"; +import ora from "ora"; +import { Brain } from "@/brain"; +import { logger } from "@/utils/logger"; +import { + type Availability, + type DailySchedule, + type MonthlySchedule, + formatDateKey, + nextMonth, + pad2, +} from "@/brain/schedule"; + +export interface ScheduleOptions { + message: string; + personality: string; +} + +export type DailyRunResult = + | { + ok: true; + kind: "daily"; + dateKey: string; + tomorrow: Date; + schedule: DailySchedule; + availability: Availability[]; + } + | { ok: false; error: string }; + +export type MonthlyRunResult = + | { + ok: true; + kind: "monthly"; + monthKey: string; + daysInMonth: number; + schedule: MonthlySchedule; + } + | { ok: false; error: string }; + +export async function runDebugScheduleDaily( + opts: ScheduleOptions, +): Promise { + const today = new Date(); + const tomorrow = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + 1, + ); + const dateKey = formatDateKey(tomorrow); + + const brain = await Brain.createDebug({ personality: opts.personality }); + + const scheduleSpinner = ora( + `Generating daily schedule for ${dateKey}...`, + ).start(); + const schedule = await brain.createDailySchedule(today, opts.message); + if (!schedule) { + scheduleSpinner.fail("Daily schedule generation failed"); + return { ok: false, error: "Daily schedule generation failed" }; + } + scheduleSpinner.succeed( + `Daily schedule generated (${schedule.length} slots)`, + ); + + printSection( + `Daily Schedule — ${dateKey} (${tomorrow.toLocaleDateString("en-US", { weekday: "long" })})`, + ); + console.log(JSON.stringify(schedule, null, 2)); + + const availSpinner = ora("Deriving availability...").start(); + const availability = await brain.deriveAvailabilityFromSchedule(schedule); + if (!availability) { + availSpinner.fail("Availability derivation failed"); + return { ok: false, error: "Availability derivation failed" }; + } + availSpinner.succeed( + `Availability derived (${availability.length} windows)`, + ); + + printSection(`Availability — ${dateKey}`); + console.log(JSON.stringify(availability, null, 2)); + + logger.info("Debug run complete. Nothing was written to disk."); + + return { + ok: true, + kind: "daily", + dateKey, + tomorrow, + schedule, + availability, + }; +} + +export async function runDebugScheduleMonthly( + opts: ScheduleOptions, +): Promise { + const today = new Date(); + const next = nextMonth(today); + const monthKey = `${next.year}-${pad2(next.month + 1)}`; + + const brain = await Brain.createDebug({ personality: opts.personality }); + + const scheduleSpinner = ora( + `Generating monthly schedule for ${monthKey} (${next.daysInMonth} days)...`, + ).start(); + const schedule = await brain.createMonthlySchedule(today, opts.message); + if (!schedule) { + scheduleSpinner.fail("Monthly schedule generation failed"); + return { ok: false, error: "Monthly schedule generation failed" }; + } + scheduleSpinner.succeed( + `Monthly schedule generated (${schedule.length} day summaries)`, + ); + + printSection(`Monthly Schedule — ${monthKey} (${next.daysInMonth} days)`); + console.log(JSON.stringify(schedule, null, 2)); + + logger.info( + "Debug run complete. Nothing was written to disk. (Availability applies per-day and is not generated for the monthly view.)", + ); + + return { + ok: true, + kind: "monthly", + monthKey, + daysInMonth: next.daysInMonth, + schedule, + }; +} + +export function addScheduleSubcommand(parent: Command): Command { + const cmd = parent + .command("schedule") + .description("Generate a test schedule (no disk writes)"); + + cmd.command("daily") + .description( + "Generate a daily schedule for tomorrow and print schedule + availability", + ) + .requiredOption("-m, --message ", "User direction for the schedule") + .requiredOption("-p, --personality ", "Brain personality to use") + .action(async (opts: ScheduleOptions) => { + const result = await runDebugScheduleDaily(opts); + if (!result.ok) { + logger.error(result.error); + process.exit(1); + } + }); + + cmd.command("monthly") + .description("Generate a monthly schedule for next month and print it") + .requiredOption("-m, --message ", "User direction for the schedule") + .requiredOption("-p, --personality ", "Brain personality to use") + .action(async (opts: ScheduleOptions) => { + const result = await runDebugScheduleMonthly(opts); + if (!result.ok) { + logger.error(result.error); + process.exit(1); + } + }); + + return cmd; +} + +function printSection(title: string): void { + const line = "─".repeat(Math.max(40, title.length + 4)); + console.log(`\n┌${line}┐`); + console.log(`│ ${title}`); + console.log(`└${line}┘`); +} diff --git a/src/commands/index.test.ts b/src/commands/index.test.ts new file mode 100644 index 0000000..abb6265 --- /dev/null +++ b/src/commands/index.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { Command } from "commander"; +import { registerCommand } from "./index"; + +describe("registerCommand", () => { + test("attaches a subcommand with the given name and description", () => { + const program = new Command(); + const cmd = registerCommand(program, { + name: "foo", + description: "Foo command", + }); + expect(cmd).toBeDefined(); + expect(cmd.name()).toBe("foo"); + expect(cmd.description()).toBe("Foo command"); + }); + + test("returns the newly-created subcommand", () => { + const program = new Command(); + const cmd = registerCommand(program, { + name: "bar", + description: "Bar command", + }); + expect(program.commands.find((c) => c.name() === "bar")).toBe(cmd); + }); + + test("invokes the configure callback with the new subcommand", () => { + const program = new Command(); + let received: Command | undefined; + registerCommand(program, { + name: "baz", + description: "Baz command", + configure: (cmd) => { + received = cmd; + cmd.option("-x, --xtra ", "extra"); + }, + }); + expect(received).toBeDefined(); + expect(received!.name()).toBe("baz"); + const baz = program.commands.find((c) => c.name() === "baz"); + expect(baz?.options.find((o) => o.long === "--xtra")).toBeDefined(); + }); + + test("omitting configure is allowed and does not throw", () => { + const program = new Command(); + expect(() => + registerCommand(program, { name: "qux", description: "Qux" }), + ).not.toThrow(); + expect(program.commands.find((c) => c.name() === "qux")).toBeDefined(); + }); +}); diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..1ea0559 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,16 @@ +import type { Command } from "commander"; + +export interface CommandConfig { + name: string; + description: string; + configure?: (cmd: Command) => void; +} + +export function registerCommand( + program: Command, + config: CommandConfig, +): Command { + const cmd = program.command(config.name).description(config.description); + config.configure?.(cmd); + return cmd; +} diff --git a/src/commands/run.ts b/src/commands/run.ts index 418a302..3845ed8 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,3 +1,11 @@ -// Run brain +import type { Command } from "commander"; +import { registerCommand } from "@/commands"; export async function run() {} + +export function register(program: Command): Command { + return registerCommand(program, { + name: "run", + description: "Run BrainBox", + }).action(run); +} diff --git a/src/index.ts b/src/index.ts index 507513b..2805cb9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,9 @@ import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { logger } from "@/utils/logger"; -import { run } from "@/commands/run"; -import { brain } from "@/commands/brain"; +import { register as run } from "@/commands/run"; +import { register as brain } from "@/commands/brain"; +import { register as debug } from "@/commands/debug"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -32,8 +33,9 @@ program outputError: (str) => logger.error(str.replace("error: ", "")), }); -program.command("run").description("Run BrainBox").action(run); -program.command("brain").description("Manage brains").action(brain); +run(program); +brain(program); +debug(program); program.on("command:*", () => { logger.error(`Unknown command: ${program.args.join(" ")}`);