diff --git a/src/brain/index.ts b/src/brain/index.ts index b15b9bb..2d75302 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { config } from "@/config"; -import { IdentityDB, type Space } from "identitydb"; +import { IdentityDB, type ExtractedFact, type Space } from "identitydb"; import { llm } from "@/openrouter"; import { loadPrompt } from "@/openrouter/promptLoader"; import { @@ -28,6 +28,19 @@ export interface DebugOptions { personality: string; } +export interface BrainCreateResult { + brain: Brain; + description: string; + baseSystemPrompt: string; + /** + * Raw facts as returned by `factExtractor.extract(description)`. Populated + * only when `Brain.create` is called with `debug: true`; in production + * (the default), facts are persisted via `db.ingestStatements` which does + * not surface the raw extractor output to the caller. + */ + extractedFacts?: ExtractedFact[]; +} + export class Brain { private availabilityCache: Map = new Map(); @@ -280,8 +293,8 @@ export class Brain { static async create( displayName: string, seed: string, - options: { dbPath?: string; braindbPath?: string } = {}, - ): Promise { + options: { dbPath?: string; braindbPath?: string; debug?: boolean } = {}, + ): Promise { const dbPath = options.dbPath ?? config.dbPath; const manager = options.braindbPath ? new BrainDBManager(options.braindbPath) @@ -321,10 +334,26 @@ export class Brain { description: displayName, }); - await db.ingestStatement(description, { - extractor: factExtractor, - spaceName, - }); + let extractedFacts: ExtractedFact[] | undefined; + if (options.debug) { + extractedFacts = await factExtractor.extract(description); + for (const fact of extractedFacts) { + await db.addFact({ + spaceName, + statement: fact.statement ?? description, + summary: fact.summary, + source: fact.source, + confidence: fact.confidence, + topics: fact.topics, + metadata: fact.metadata, + }); + } + } else { + await db.ingestStatements(description, { + extractor: factExtractor, + spaceName, + }); + } const brainbase: BrainItem = { brainId, @@ -334,7 +363,8 @@ export class Brain { }; await manager.saveBrain(brainId, brainbase); - return new Brain(db, space, brainbase); + const brain = new Brain(db, space, brainbase); + return { brain, description, baseSystemPrompt, extractedFacts }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); logger.error(`Failed to create brain "${displayName}": ${reason}`); diff --git a/src/commands/debug/brain.test.ts b/src/commands/debug/brain.test.ts index 6e3aae0..7ea7ca4 100644 --- a/src/commands/debug/brain.test.ts +++ b/src/commands/debug/brain.test.ts @@ -1,5 +1,8 @@ +import { randomUUID } from "node:crypto"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { existsSync, readdirSync } from "fs"; +import { unlink, writeFile } from "fs/promises"; +import type { ExtractedFact } from "identitydb"; import { tmpdir } from "os"; interface RecordedCall { @@ -16,18 +19,7 @@ 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; - }>; -}> = [ +const EXTRACTED_FACTS: ExtractedFact[] = [ { statement: "Maren is 34 years old.", summary: "Maren is 34 years old.", @@ -96,6 +88,7 @@ mock.module("@/config", () => ({ })); const { runDebugBrainInit } = await import("./brain"); +const { Brain: ProdBrain } = await import("@/brain"); beforeEach(() => { llmCalls.length = 0; @@ -115,7 +108,7 @@ afterEach(async () => { }); describe("runDebugBrainInit", () => { - test("B1: returns ok result with brainId, spaceName, baseSystemPrompt, and uses the supplied seed", async () => { + test("B1: returns ok result with full description, baseSystemPrompt, extractedFacts, and uses the supplied seed", async () => { const result = await runDebugBrainInit({ displayName: "Maren", seed: "Maren, 34, night-shift nurse, hides exhaustion behind sarcasm", @@ -131,6 +124,8 @@ describe("runDebugBrainInit", () => { ); expect(result.spaceName).toBe(`brain:${result.brainId}`); + expect(result.description).toBe(PERSONA_DESCRIPTION); + expect(result.baseSystemPrompt).toContain(GENERATED_BASE_SYSTEM_PROMPT); expect(result.baseSystemPrompt).toContain("You exist in a text chat."); expect(result.baseSystemPrompt).toBe( @@ -139,6 +134,8 @@ describe("runDebugBrainInit", () => { GENERATED_BASE_SYSTEM_PROMPT.length + 2, ), ); + + expect(result.extractedFacts).toEqual(EXTRACTED_FACTS); }); test("B2: invokes the LLM exactly 3 times — PERSONA_INIT, PERSONA_BASE_SYSTEM_PROMPT, fact-extractor", async () => { @@ -207,3 +204,49 @@ describe("runDebugBrainInit", () => { expect(result.ok).toBe(true); }); }); + +describe("Brain.create (production path — debug: false)", () => { + test("B6: with debug omitted (default), uses db.ingestStatements and does NOT return extractedFacts", async () => { + const braindbPath = `${tmpdir()}/brainbox-prod-brain-${randomUUID()}.json`; + await writeFile(braindbPath, "{}", { encoding: "utf-8" }); + + const result = await ProdBrain.create("ProdMaren", "a prod seed", { + dbPath: ":memory:", + braindbPath, + }); + + try { + expect(result).not.toBeNull(); + if (!result) throw new Error("expected result"); + expect(result.brain).toBeDefined(); + expect(result.description).toBe(PERSONA_DESCRIPTION); + expect(result.baseSystemPrompt).toContain(GENERATED_BASE_SYSTEM_PROMPT); + expect(result.extractedFacts).toBeUndefined(); + } finally { + try { + await unlink(braindbPath); + } catch {} + } + }); + + test("B7: with debug: false (explicit), same as default — uses db.ingestStatements, no extractedFacts", async () => { + const braindbPath = `${tmpdir()}/brainbox-prod-brain-${randomUUID()}.json`; + await writeFile(braindbPath, "{}", { encoding: "utf-8" }); + + const result = await ProdBrain.create("ProdMaren2", "seed", { + dbPath: ":memory:", + braindbPath, + debug: false, + }); + + try { + expect(result).not.toBeNull(); + if (!result) throw new Error("expected result"); + expect(result.extractedFacts).toBeUndefined(); + } finally { + try { + await unlink(braindbPath); + } catch {} + } + }); +}); diff --git a/src/commands/debug/brain.ts b/src/commands/debug/brain.ts index 6459484..e771cae 100644 --- a/src/commands/debug/brain.ts +++ b/src/commands/debug/brain.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import type { Command } from "commander"; import ora from "ora"; +import type { ExtractedFact } from "identitydb"; import { Brain } from "@/brain"; import { logger } from "@/utils/logger"; @@ -19,18 +20,26 @@ export type BrainInitResult = displayName: string; brainId: string; spaceName: string; + description: string; baseSystemPrompt: string; + extractedFacts: ExtractedFact[]; } | { 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. + * LLM calls → SQLite DB upsert → fact extraction via `factExtractor.extract` → + * 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. + * + * Prints the full text of: + * 1. the generated `description` (PERSONA_INIT output) + * 2. the concatenated `baseSystemPrompt` (generated + fixed) + * 3. the `extractedFacts` (obtained by directly calling + * `factExtractor.extract(description)`) */ export async function runDebugBrainInit( opts: BrainInitOptions, @@ -44,27 +53,53 @@ export async function runDebugBrainInit( `Initializing brain "${opts.displayName}" with LLM (debug, no real disk state)...`, ).start(); try { - const brain = await Brain.create(opts.displayName, opts.seed, { + const result = await Brain.create(opts.displayName, opts.seed, { dbPath: ":memory:", braindbPath, + debug: true, }); - if (!brain) { + if (!result) { spinner.fail("Brain initialization failed"); return { ok: false, error: "Brain initialization failed" }; } + const { + brain, + description, + baseSystemPrompt, + extractedFacts, + } = result; + const factCount = extractedFacts?.length ?? 0; spinner.succeed( - `Brain initialized (id=${brain.brainbase.brainId}, space=${brain.brainbase.spaceName})`, + `Brain initialized (id=${brain.brainbase.brainId}, space=${brain.brainbase.spaceName}, ${factCount} fact(s) extracted)`, ); + printSection(`Description (PERSONA_INIT output)`); + console.log(description); + console.log(); + + printSection(`baseSystemPrompt (PERSONA_BASE_SYSTEM_PROMPT + FIXED)`); + console.log(baseSystemPrompt); + console.log(); + 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 ? "..." : ""}`, + `Extracted facts (factExtractor.extract — ${factCount})`, ); + if (extractedFacts && extractedFacts.length > 0) { + extractedFacts.forEach((fact, i) => { + console.log(`\n[${i + 1}/${extractedFacts.length}]`); + console.log(` statement: ${fact.statement ?? ""}`); + console.log(` summary: ${fact.summary ?? ""}`); + console.log(` source: ${fact.source ?? ""}`); + console.log(` confidence: ${fact.confidence ?? ""}`); + console.log(` topics: ${JSON.stringify(fact.topics)}`); + if (fact.metadata) { + console.log(` metadata: ${JSON.stringify(fact.metadata)}`); + } + }); + } else { + console.log(" (no facts extracted)"); + } + console.log(); logger.info("Debug run complete. Nothing was written to real disk."); @@ -74,14 +109,15 @@ export async function runDebugBrainInit( displayName: opts.displayName, brainId: brain.brainbase.brainId, spaceName: brain.brainbase.spaceName, - baseSystemPrompt: brain.brainbase.baseSystemPrompt, + description, + baseSystemPrompt, + extractedFacts: extractedFacts ?? [], }; } 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 {}