feat: add debug command for debugging schedule generation
This commit is contained in:
@@ -399,3 +399,55 @@ describe("S8: regression on existing methods", () => {
|
|||||||
expect(typeof Brain.load).toBe("function");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
nextMonth,
|
nextMonth,
|
||||||
pad2,
|
pad2,
|
||||||
} from "./schedule";
|
} from "./schedule";
|
||||||
|
import { BadRequestResponseError } from "@openrouter/sdk/models/errors";
|
||||||
|
|
||||||
|
export interface DebugOptions {
|
||||||
|
personality: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class Brain {
|
export class Brain {
|
||||||
private availabilityCache: Map<string, Availability[]> = new Map();
|
private availabilityCache: Map<string, Availability[]> = new Map();
|
||||||
@@ -29,6 +34,7 @@ export class Brain {
|
|||||||
public db: IdentityDB,
|
public db: IdentityDB,
|
||||||
public space: Space,
|
public space: Space,
|
||||||
public brainbase: BrainItem,
|
public brainbase: BrainItem,
|
||||||
|
public debug: boolean = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createDailySchedule(
|
async createDailySchedule(
|
||||||
@@ -62,37 +68,44 @@ export class Brain {
|
|||||||
jsonSchema: dailyScheduleSchema,
|
jsonSchema: dailyScheduleSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.addFact({
|
if (!this.debug) {
|
||||||
spaceName: this.space.name,
|
await this.db.addFact({
|
||||||
statement: JSON.stringify(schedule),
|
spaceName: this.space.name,
|
||||||
summary: `Daily schedule for ${dateKey} (${schedule.length} slots)`,
|
statement: JSON.stringify(schedule),
|
||||||
source: "createDailySchedule",
|
summary: `Daily schedule for ${dateKey} (${schedule.length} slots)`,
|
||||||
confidence: 1.0,
|
source: "createDailySchedule",
|
||||||
topics: [
|
confidence: 1.0,
|
||||||
{
|
topics: [
|
||||||
name: topicName,
|
{
|
||||||
category: "temporal",
|
name: topicName,
|
||||||
granularity: "concrete",
|
category: "temporal",
|
||||||
role: "schedule",
|
granularity: "concrete",
|
||||||
},
|
role: "schedule",
|
||||||
{
|
},
|
||||||
name: "daily-schedule",
|
{
|
||||||
category: "concept",
|
name: "daily-schedule",
|
||||||
granularity: "abstract",
|
category: "concept",
|
||||||
role: "schedule",
|
granularity: "abstract",
|
||||||
},
|
role: "schedule",
|
||||||
{
|
},
|
||||||
name: dateKey,
|
{
|
||||||
category: "temporal",
|
name: dateKey,
|
||||||
granularity: "concrete",
|
category: "temporal",
|
||||||
role: "date",
|
granularity: "concrete",
|
||||||
},
|
role: "date",
|
||||||
],
|
},
|
||||||
});
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
} catch (error) {
|
} 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}`);
|
logger.error(`createDailySchedule failed: ${reason}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -125,33 +138,35 @@ export class Brain {
|
|||||||
jsonSchema: monthlyScheduleSchema,
|
jsonSchema: monthlyScheduleSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.addFact({
|
if (!this.debug) {
|
||||||
spaceName: this.space.name,
|
await this.db.addFact({
|
||||||
statement: JSON.stringify(schedule),
|
spaceName: this.space.name,
|
||||||
summary: `Monthly schedule for ${monthKey} (${schedule.length} days)`,
|
statement: JSON.stringify(schedule),
|
||||||
source: "createMonthlySchedule",
|
summary: `Monthly schedule for ${monthKey} (${schedule.length} days)`,
|
||||||
confidence: 1.0,
|
source: "createMonthlySchedule",
|
||||||
topics: [
|
confidence: 1.0,
|
||||||
{
|
topics: [
|
||||||
name: topicName,
|
{
|
||||||
category: "temporal",
|
name: topicName,
|
||||||
granularity: "concrete",
|
category: "temporal",
|
||||||
role: "schedule",
|
granularity: "concrete",
|
||||||
},
|
role: "schedule",
|
||||||
{
|
},
|
||||||
name: "monthly-schedule",
|
{
|
||||||
category: "concept",
|
name: "monthly-schedule",
|
||||||
granularity: "abstract",
|
category: "concept",
|
||||||
role: "schedule",
|
granularity: "abstract",
|
||||||
},
|
role: "schedule",
|
||||||
{
|
},
|
||||||
name: monthKey,
|
{
|
||||||
category: "temporal",
|
name: monthKey,
|
||||||
granularity: "concrete",
|
category: "temporal",
|
||||||
role: "period",
|
granularity: "concrete",
|
||||||
},
|
role: "period",
|
||||||
],
|
},
|
||||||
});
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -169,6 +184,13 @@ export class Brain {
|
|||||||
const cached = this.availabilityCache.get(dateKey);
|
const cached = this.availabilityCache.get(dateKey);
|
||||||
if (cached) return cached;
|
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 topicName = `daily-schedule:${dateKey}`;
|
||||||
const facts = await this.db.getTopicFacts(topicName, {
|
const facts = await this.db.getTopicFacts(topicName, {
|
||||||
spaceName: this.space.name,
|
spaceName: this.space.name,
|
||||||
@@ -176,19 +198,8 @@ export class Brain {
|
|||||||
if (facts.length === 0) return null;
|
if (facts.length === 0) return null;
|
||||||
|
|
||||||
const dailySchedule = JSON.parse(facts[0]!.statement) as DailySchedule;
|
const dailySchedule = JSON.parse(facts[0]!.statement) as DailySchedule;
|
||||||
|
const availability =
|
||||||
const instruction = await loadPrompt("SCHEDULE_AVAILABILITY");
|
await this.deriveAvailabilityFromSchedule(dailySchedule);
|
||||||
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);
|
this.availabilityCache.set(dateKey, availability);
|
||||||
return availability;
|
return availability;
|
||||||
@@ -199,11 +210,35 @@ export class Brain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deriveAvailabilityFromSchedule(
|
||||||
|
schedule: DailySchedule,
|
||||||
|
): Promise<Availability[]> {
|
||||||
|
try {
|
||||||
|
const instruction = await loadPrompt("SCHEDULE_AVAILABILITY");
|
||||||
|
const promptMessage = JSON.stringify({
|
||||||
|
schedule,
|
||||||
|
personality: this.brainbase.baseSystemPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await llm.call<Availability[]>(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 {
|
removeScheduledAvailability(): void {
|
||||||
this.availabilityCache.clear();
|
this.availabilityCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMonthlySummaryForDay(target: Date): Promise<string | null> {
|
private async getMonthlySummaryForDay(target: Date): Promise<string | null> {
|
||||||
|
if (this.debug) return null;
|
||||||
try {
|
try {
|
||||||
const monthKey = formatMonthKey(target);
|
const monthKey = formatMonthKey(target);
|
||||||
const topicName = `monthly-schedule:${monthKey}`;
|
const topicName = `monthly-schedule:${monthKey}`;
|
||||||
@@ -222,6 +257,7 @@ export class Brain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getHistoryFacts(): Promise<string> {
|
private async getHistoryFacts(): Promise<string> {
|
||||||
|
if (this.debug) return "";
|
||||||
try {
|
try {
|
||||||
const topics = await this.db.listTopics({
|
const topics = await this.db.listTopics({
|
||||||
spaceName: this.space.name,
|
spaceName: this.space.name,
|
||||||
@@ -306,4 +342,25 @@ export class Brain {
|
|||||||
|
|
||||||
return new Brain(db, space, brain);
|
return new Brain(db, space, brain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async createDebug(options: DebugOptions): Promise<Brain> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
// Manage brains
|
import type { Command } from "commander";
|
||||||
|
import { registerCommand } from "@/commands";
|
||||||
|
|
||||||
export async function brain() {}
|
export async function brain() {}
|
||||||
|
|
||||||
|
export function register(program: Command): Command {
|
||||||
|
return registerCommand(program, {
|
||||||
|
name: "brain",
|
||||||
|
description: "Manage brains",
|
||||||
|
}).action(brain);
|
||||||
|
}
|
||||||
|
|||||||
12
src/commands/debug/index.ts
Normal file
12
src/commands/debug/index.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
184
src/commands/debug/schedule.test.ts
Normal file
184
src/commands/debug/schedule.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
src/commands/debug/schedule.ts
Normal file
171
src/commands/debug/schedule.ts
Normal file
@@ -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<DailyRunResult> {
|
||||||
|
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<MonthlyRunResult> {
|
||||||
|
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 <text>", "User direction for the schedule")
|
||||||
|
.requiredOption("-p, --personality <text>", "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 <text>", "User direction for the schedule")
|
||||||
|
.requiredOption("-p, --personality <text>", "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}┘`);
|
||||||
|
}
|
||||||
50
src/commands/index.test.ts
Normal file
50
src/commands/index.test.ts
Normal file
@@ -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 <value>", "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();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/commands/index.ts
Normal file
16
src/commands/index.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
// Run brain
|
import type { Command } from "commander";
|
||||||
|
import { registerCommand } from "@/commands";
|
||||||
|
|
||||||
export async function run() {}
|
export async function run() {}
|
||||||
|
|
||||||
|
export function register(program: Command): Command {
|
||||||
|
return registerCommand(program, {
|
||||||
|
name: "run",
|
||||||
|
description: "Run BrainBox",
|
||||||
|
}).action(run);
|
||||||
|
}
|
||||||
|
|||||||
10
src/index.ts
10
src/index.ts
@@ -4,8 +4,9 @@ import { readFileSync } from "fs";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { run } from "@/commands/run";
|
import { register as run } from "@/commands/run";
|
||||||
import { brain } from "@/commands/brain";
|
import { register as brain } from "@/commands/brain";
|
||||||
|
import { register as debug } from "@/commands/debug";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -32,8 +33,9 @@ program
|
|||||||
outputError: (str) => logger.error(str.replace("error: ", "")),
|
outputError: (str) => logger.error(str.replace("error: ", "")),
|
||||||
});
|
});
|
||||||
|
|
||||||
program.command("run").description("Run BrainBox").action(run);
|
run(program);
|
||||||
program.command("brain").description("Manage brains").action(brain);
|
brain(program);
|
||||||
|
debug(program);
|
||||||
|
|
||||||
program.on("command:*", () => {
|
program.on("command:*", () => {
|
||||||
logger.error(`Unknown command: ${program.args.join(" ")}`);
|
logger.error(`Unknown command: ${program.args.join(" ")}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user