From 94cf3572f1f832aa366a920de440a364da51f6ff Mon Sep 17 00:00:00 2001 From: p-sw Date: Sun, 7 Jun 2026 14:37:44 +0900 Subject: [PATCH] feat: add brain init debug --- src/brain/index.ts | 11 +- src/brain/manager.ts | 6 +- src/commands/debug/brain.test.ts | 209 +++++++++++++++++++++++++++++++ src/commands/debug/brain.ts | 127 +++++++++++++++++++ src/commands/debug/index.ts | 6 +- 5 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 src/commands/debug/brain.test.ts create mode 100644 src/commands/debug/brain.ts diff --git a/src/brain/index.ts b/src/brain/index.ts index 231d5dd..4d25c24 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -13,7 +13,7 @@ import { } from "@/openrouter/schema"; import { logger } from "@/utils/logger"; import { factExtractor } from "./factExtractor"; -import { brainManager, type BrainItem } from "./manager"; +import { BrainDBManager, brainManager, type BrainItem } from "./manager"; import { formatDateKey, formatMonthKey, @@ -279,7 +279,12 @@ export class Brain { static async create( displayName: string, seed: string, + options: { dbPath?: string; braindbPath?: string } = {}, ): Promise { + const dbPath = options.dbPath ?? config.dbPath; + const manager = options.braindbPath + ? new BrainDBManager(options.braindbPath) + : brainManager; try { const personaInitInstruction = await loadPrompt("PERSONA_INIT"); const description = await llm.call(llm.models.identity, { @@ -305,7 +310,7 @@ export class Brain { const db = await IdentityDB.connect({ client: "sqlite", - filename: config.dbPath, + filename: dbPath, }); await db.initialize(); const brainId = randomUUID(); @@ -326,7 +331,7 @@ export class Brain { displayName, baseSystemPrompt, }; - await brainManager.saveBrain(brainId, brainbase); + await manager.saveBrain(brainId, brainbase); return new Brain(db, space, brainbase); } catch (error) { diff --git a/src/brain/manager.ts b/src/brain/manager.ts index 2b221ea..1a0c935 100644 --- a/src/brain/manager.ts +++ b/src/brain/manager.ts @@ -10,8 +10,10 @@ export interface BrainItem { export type BrainDB = Record; export class BrainDBManager { + constructor(private readonly braindbPath: string = config.braindbPath) {} + private get db() { - return readFile(config.braindbPath, { encoding: "utf-8" }).then( + return readFile(this.braindbPath, { encoding: "utf-8" }).then( (content) => { return JSON.parse(content) as BrainDB; }, @@ -19,7 +21,7 @@ export class BrainDBManager { } private async writeDb(db: BrainDB) { - await writeFile(config.braindbPath, JSON.stringify(db), { + await writeFile(this.braindbPath, JSON.stringify(db), { encoding: "utf-8", }); } diff --git a/src/commands/debug/brain.test.ts b/src/commands/debug/brain.test.ts new file mode 100644 index 0000000..04a2a23 --- /dev/null +++ b/src/commands/debug/brain.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { existsSync, readdirSync } from "fs"; +import { tmpdir } from "os"; + +interface RecordedCall { + model: unknown; + options: { + jsonSchemaName?: string; + instruction?: string; + message?: string; + }; +} + +const llmCalls: RecordedCall[] = []; + +const PERSONA_DESCRIPTION = "A 34yo night-shift nurse, hides exhaustion behind sarcasm."; +const GENERATED_BASE_SYSTEM_PROMPT = + "You are Maren. You text in lowercase. You use '...' when tired."; +const EXTRACTED_FACTS: Array<{ + statement: string; + summary: string; + source: string; + confidence: number; + topics: Array<{ + name: string; + category: string; + granularity: string; + role: string; + }>; +}> = [ + { + statement: "Maren is 34 years old.", + summary: "Maren is 34 years old.", + source: "persona-init", + confidence: 1.0, + topics: [ + { + name: "maren-age", + category: "temporal", + granularity: "concrete", + role: "attribute", + }, + ], + }, + { + statement: "Maren is a night-shift nurse.", + summary: "Maren is a night-shift nurse.", + source: "persona-init", + confidence: 1.0, + topics: [ + { + name: "maren-occupation", + category: "entity", + granularity: "concrete", + role: "attribute", + }, + ], + }, +]; + +const mockCall = mock(async (model: unknown, options: any): Promise => { + llmCalls.push({ model, options }); + if ( + options.instruction?.includes("depth psychologist") || + options.instruction?.includes("forensic biographer") + ) { + return PERSONA_DESCRIPTION as unknown as T; + } + if ( + options.instruction?.includes("prompt engineer") || + options.instruction?.includes("LLM character embodiment") + ) { + return GENERATED_BASE_SYSTEM_PROMPT as unknown as T; + } + if (options.jsonSchemaName === "fact-extractor") { + return EXTRACTED_FACTS as unknown as T; + } + throw new Error( + `unexpected LLM call: model=${model} instruction=${options.instruction?.slice(0, 80)}`, + ); +}); + +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-brain-IGNORED.json", + }, +})); + +const { runDebugBrainInit } = await import("./brain"); + +beforeEach(() => { + llmCalls.length = 0; + mockCall.mockClear(); +}); + +afterEach(async () => { + const tmpFiles = readdirSync(tmpdir()).filter((f) => + f.startsWith("brainbox-debug-brain-"), + ); + for (const f of tmpFiles) { + try { + const { unlink } = await import("fs/promises"); + await unlink(`${tmpdir()}/${f}`); + } catch {} + } +}); + +describe("runDebugBrainInit", () => { + test("B1: returns ok result with brainId, spaceName, baseSystemPrompt, and uses the supplied seed", async () => { + const result = await runDebugBrainInit({ + displayName: "Maren", + seed: "Maren, 34, night-shift nurse, hides exhaustion behind sarcasm", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.kind).toBe("init"); + expect(result.displayName).toBe("Maren"); + expect(result.brainId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(result.spaceName).toBe(`brain:${result.brainId}`); + + expect(result.baseSystemPrompt).toContain(GENERATED_BASE_SYSTEM_PROMPT); + expect(result.baseSystemPrompt).toContain("You exist in a text chat."); + expect(result.baseSystemPrompt).toBe( + `${GENERATED_BASE_SYSTEM_PROMPT}\n\n` + + result.baseSystemPrompt.slice( + GENERATED_BASE_SYSTEM_PROMPT.length + 2, + ), + ); + }); + + test("B2: invokes the LLM exactly 3 times — PERSONA_INIT, PERSONA_BASE_SYSTEM_PROMPT, fact-extractor", async () => { + await runDebugBrainInit({ + displayName: "Test", + seed: "a seed", + }); + + expect(llmCalls.length).toBe(3); + + const initCall = llmCalls[0]!; + expect(initCall.options.message).toBe("a seed"); + expect(initCall.options.jsonSchemaName).toBeUndefined(); + + const systemCall = llmCalls[1]!; + expect(systemCall.options.jsonSchemaName).toBeUndefined(); + expect(systemCall.options.message).toBe(PERSONA_DESCRIPTION); + + const factCall = llmCalls[2]!; + expect(factCall.options.jsonSchemaName).toBe("fact-extractor"); + expect(factCall.options.message).toBe(PERSONA_DESCRIPTION); + }); + + test("B3: writes no real on-disk state — no brainbox.db, no brainbox.json, no leftover temp braindb in /tmp", async () => { + const cwd = process.cwd(); + + const beforeDb = existsSync(`${cwd}/brainbox.db`); + const beforeJson = existsSync(`${cwd}/brainbox.json`); + const beforeTmp = readdirSync(tmpdir()).filter((f) => + f.startsWith("brainbox-debug-brain-"), + ); + + await runDebugBrainInit({ displayName: "NoDiskCheck", seed: "x" }); + + const afterDb = existsSync(`${cwd}/brainbox.db`); + const afterJson = existsSync(`${cwd}/brainbox.json`); + const afterTmp = readdirSync(tmpdir()).filter((f) => + f.startsWith("brainbox-debug-brain-"), + ); + + expect(afterDb).toBe(beforeDb); + expect(afterJson).toBe(beforeJson); + expect(afterTmp).toHaveLength(0); + }); + + test("B4: when Brain.create returns null (e.g. LLM throws), result is {ok: false, error}", async () => { + mockCall.mockImplementationOnce(async () => { + throw new Error("simulated LLM failure on PERSONA_INIT"); + }); + + const result = await runDebugBrainInit({ + displayName: "Doomed", + seed: "x", + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected !ok"); + expect(result.error).toMatch(/Brain initialization failed/); + }); + + test("B5: with no DB_PATH / BRAINDB_PATH env, runDebugBrainInit still works (no env dependency)", async () => { + const result = await runDebugBrainInit({ + displayName: "EnvFree", + seed: "no env", + }); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/commands/debug/brain.ts b/src/commands/debug/brain.ts new file mode 100644 index 0000000..6459484 --- /dev/null +++ b/src/commands/debug/brain.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { unlink, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Command } from "commander"; +import ora from "ora"; +import { Brain } from "@/brain"; +import { logger } from "@/utils/logger"; + +export interface BrainInitOptions { + displayName: string; + seed: string; +} + +export type BrainInitResult = + | { + ok: true; + kind: "init"; + displayName: string; + brainId: string; + spaceName: string; + baseSystemPrompt: string; + } + | { ok: false; error: string }; + +/** + * Exercise the full `Brain.create` flow (PERSONA_INIT → PERSONA_BASE_SYSTEM_PROMPT + * LLM calls → SQLite DB upsert → fact extraction → braindb save) without + * touching real on-disk state. + * + * - SQLite DB uses `:memory:` (ephemeral, dies with the process). + * - The braindb JSON is written to a fresh temp file under `os.tmpdir()` + * and unlinked after the run. + */ +export async function runDebugBrainInit( + opts: BrainInitOptions, +): Promise { + const braindbPath = join( + tmpdir(), + `brainbox-debug-brain-${randomUUID()}.json`, + ); + await writeFile(braindbPath, "{}", { encoding: "utf-8" }); + const spinner = ora( + `Initializing brain "${opts.displayName}" with LLM (debug, no real disk state)...`, + ).start(); + try { + const brain = await Brain.create(opts.displayName, opts.seed, { + dbPath: ":memory:", + braindbPath, + }); + if (!brain) { + spinner.fail("Brain initialization failed"); + return { ok: false, error: "Brain initialization failed" }; + } + spinner.succeed( + `Brain initialized (id=${brain.brainbase.brainId}, space=${brain.brainbase.spaceName})`, + ); + + printSection( + `Brain — ${opts.displayName} (${brain.brainbase.brainId})`, + ); + console.log(`spaceName: ${brain.brainbase.spaceName}`); + console.log(`displayName: ${brain.brainbase.displayName}`); + console.log(`baseSystemPrompt (first 240 chars):`); + console.log( + ` ${brain.brainbase.baseSystemPrompt.slice(0, 240).replace(/\n/g, "\n ")}${brain.brainbase.baseSystemPrompt.length > 240 ? "..." : ""}`, + ); + + logger.info("Debug run complete. Nothing was written to real disk."); + + return { + ok: true, + kind: "init", + displayName: opts.displayName, + brainId: brain.brainbase.brainId, + spaceName: brain.brainbase.spaceName, + baseSystemPrompt: brain.brainbase.baseSystemPrompt, + }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + spinner.fail("Brain initialization failed"); + return { ok: false, error: reason }; + } finally { + // Clean up the temp braindb file regardless of success/failure. + try { + await unlink(braindbPath); + } catch {} + } +} + +export function addBrainSubcommand(parent: Command): Command { + const cmd = parent + .command("brain") + .description( + "Debug tools for brain lifecycle (no real disk writes)", + ); + + cmd + .command("init") + .description( + "Initialize a new brain with LLM (in-memory DB, temp braindb; nothing persisted)", + ) + .requiredOption("-n, --name ", "Display name for the new brain") + .requiredOption( + "-s, --seed ", + "Seed text used to generate the persona biography", + ) + .action(async (opts: { name: string; seed: string }) => { + const result = await runDebugBrainInit({ + displayName: opts.name, + seed: opts.seed, + }); + 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/debug/index.ts b/src/commands/debug/index.ts index f04df16..c0259c7 100644 --- a/src/commands/debug/index.ts +++ b/src/commands/debug/index.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { registerCommand } from "@/commands"; +import { addBrainSubcommand } from "./brain"; import { addScheduleSubcommand } from "./schedule"; export function register(program: Command): Command { @@ -7,6 +8,9 @@ export function register(program: Command): Command { name: "debug", description: "Dry-run tools: exercise code paths without writing to the database or braindb", - configure: addScheduleSubcommand, + configure: (cmd) => { + addScheduleSubcommand(cmd); + addBrainSubcommand(cmd); + }, }); }