feat: add brain init debug
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/openrouter/schema";
|
} from "@/openrouter/schema";
|
||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { factExtractor } from "./factExtractor";
|
import { factExtractor } from "./factExtractor";
|
||||||
import { brainManager, type BrainItem } from "./manager";
|
import { BrainDBManager, brainManager, type BrainItem } from "./manager";
|
||||||
import {
|
import {
|
||||||
formatDateKey,
|
formatDateKey,
|
||||||
formatMonthKey,
|
formatMonthKey,
|
||||||
@@ -279,7 +279,12 @@ export class Brain {
|
|||||||
static async create(
|
static async create(
|
||||||
displayName: string,
|
displayName: string,
|
||||||
seed: string,
|
seed: string,
|
||||||
|
options: { dbPath?: string; braindbPath?: string } = {},
|
||||||
): Promise<Brain | null> {
|
): Promise<Brain | null> {
|
||||||
|
const dbPath = options.dbPath ?? config.dbPath;
|
||||||
|
const manager = options.braindbPath
|
||||||
|
? new BrainDBManager(options.braindbPath)
|
||||||
|
: brainManager;
|
||||||
try {
|
try {
|
||||||
const personaInitInstruction = await loadPrompt("PERSONA_INIT");
|
const personaInitInstruction = await loadPrompt("PERSONA_INIT");
|
||||||
const description = await llm.call<string>(llm.models.identity, {
|
const description = await llm.call<string>(llm.models.identity, {
|
||||||
@@ -305,7 +310,7 @@ export class Brain {
|
|||||||
|
|
||||||
const db = await IdentityDB.connect({
|
const db = await IdentityDB.connect({
|
||||||
client: "sqlite",
|
client: "sqlite",
|
||||||
filename: config.dbPath,
|
filename: dbPath,
|
||||||
});
|
});
|
||||||
await db.initialize();
|
await db.initialize();
|
||||||
const brainId = randomUUID();
|
const brainId = randomUUID();
|
||||||
@@ -326,7 +331,7 @@ export class Brain {
|
|||||||
displayName,
|
displayName,
|
||||||
baseSystemPrompt,
|
baseSystemPrompt,
|
||||||
};
|
};
|
||||||
await brainManager.saveBrain(brainId, brainbase);
|
await manager.saveBrain(brainId, brainbase);
|
||||||
|
|
||||||
return new Brain(db, space, brainbase);
|
return new Brain(db, space, brainbase);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export interface BrainItem {
|
|||||||
export type BrainDB = Record<string, BrainItem>;
|
export type BrainDB = Record<string, BrainItem>;
|
||||||
|
|
||||||
export class BrainDBManager {
|
export class BrainDBManager {
|
||||||
|
constructor(private readonly braindbPath: string = config.braindbPath) {}
|
||||||
|
|
||||||
private get db() {
|
private get db() {
|
||||||
return readFile(config.braindbPath, { encoding: "utf-8" }).then(
|
return readFile(this.braindbPath, { encoding: "utf-8" }).then(
|
||||||
(content) => {
|
(content) => {
|
||||||
return JSON.parse(content) as BrainDB;
|
return JSON.parse(content) as BrainDB;
|
||||||
},
|
},
|
||||||
@@ -19,7 +21,7 @@ export class BrainDBManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async writeDb(db: BrainDB) {
|
private async writeDb(db: BrainDB) {
|
||||||
await writeFile(config.braindbPath, JSON.stringify(db), {
|
await writeFile(this.braindbPath, JSON.stringify(db), {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
209
src/commands/debug/brain.test.ts
Normal file
209
src/commands/debug/brain.test.ts
Normal file
@@ -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 <T>(model: unknown, options: any): Promise<T> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
src/commands/debug/brain.ts
Normal file
127
src/commands/debug/brain.ts
Normal file
@@ -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<BrainInitResult> {
|
||||||
|
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 <text>", "Display name for the new brain")
|
||||||
|
.requiredOption(
|
||||||
|
"-s, --seed <text>",
|
||||||
|
"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}┘`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { registerCommand } from "@/commands";
|
import { registerCommand } from "@/commands";
|
||||||
|
import { addBrainSubcommand } from "./brain";
|
||||||
import { addScheduleSubcommand } from "./schedule";
|
import { addScheduleSubcommand } from "./schedule";
|
||||||
|
|
||||||
export function register(program: Command): Command {
|
export function register(program: Command): Command {
|
||||||
@@ -7,6 +8,9 @@ export function register(program: Command): Command {
|
|||||||
name: "debug",
|
name: "debug",
|
||||||
description:
|
description:
|
||||||
"Dry-run tools: exercise code paths without writing to the database or braindb",
|
"Dry-run tools: exercise code paths without writing to the database or braindb",
|
||||||
configure: addScheduleSubcommand,
|
configure: (cmd) => {
|
||||||
|
addScheduleSubcommand(cmd);
|
||||||
|
addBrainSubcommand(cmd);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user