feat: implement sendMessage
This commit is contained in:
@@ -24,6 +24,21 @@ let customAvailability: Array<{
|
|||||||
status: string;
|
status: string;
|
||||||
}> | null = null;
|
}> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue of LLM responses for tool-calling flows (sendMessage). Each entry is
|
||||||
|
* returned in order. Shape matches OpenRouter's `ChatResult.choices[0]`
|
||||||
|
* reduced form: `{ content, tool_calls, finish_reason }`.
|
||||||
|
*/
|
||||||
|
type ToolCallResponse = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
type LLMChatResponse =
|
||||||
|
| { kind: "text"; text: string }
|
||||||
|
| { kind: "tool_calls"; tool_calls: ToolCallResponse[] };
|
||||||
|
let chatResponses: LLMChatResponse[] = [];
|
||||||
|
|
||||||
function build48Slots(): Array<{
|
function build48Slots(): Array<{
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
@@ -95,6 +110,32 @@ const mockCall = mock(async <T>(model: unknown, options: any): Promise<T> => {
|
|||||||
if (options.jsonSchemaName === "availability") {
|
if (options.jsonSchemaName === "availability") {
|
||||||
return { items: customAvailability ?? buildAvailability() } as unknown as T;
|
return { items: customAvailability ?? buildAvailability() } as unknown as T;
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(options.tools)) {
|
||||||
|
const next = chatResponses.shift();
|
||||||
|
if (!next) {
|
||||||
|
throw new Error("mockCall: no chatResponses queued for tool-using call");
|
||||||
|
}
|
||||||
|
if (next.kind === "text") {
|
||||||
|
return {
|
||||||
|
finish_reason: "stop",
|
||||||
|
index: 0,
|
||||||
|
message: { role: "assistant", content: next.text },
|
||||||
|
} as unknown as T;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
toolCalls: next.tool_calls.map((tc) => ({
|
||||||
|
id: tc.id,
|
||||||
|
type: "function",
|
||||||
|
function: { name: tc.name, arguments: tc.arguments },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
} as unknown as T;
|
||||||
|
}
|
||||||
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
|
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,6 +143,7 @@ mock.module("@/openrouter", () => ({
|
|||||||
llm: {
|
llm: {
|
||||||
models: { conversation: "test-conv", identity: "test-id" },
|
models: { conversation: "test-conv", identity: "test-id" },
|
||||||
call: mockCall,
|
call: mockCall,
|
||||||
|
chatWithTools: mockCall,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -126,7 +168,9 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
afterAll(async () => {});
|
afterAll(async () => {});
|
||||||
|
|
||||||
async function makeBrain(): Promise<InstanceType<typeof Brain>> {
|
async function makeBrain(
|
||||||
|
embeddingProvider: unknown = NOOP_EMBEDDING_PROVIDER,
|
||||||
|
): Promise<InstanceType<typeof Brain>> {
|
||||||
const db = await IdentityDB.connect({
|
const db = await IdentityDB.connect({
|
||||||
client: "sqlite",
|
client: "sqlite",
|
||||||
filename: ":memory:",
|
filename: ":memory:",
|
||||||
@@ -141,7 +185,7 @@ async function makeBrain(): Promise<InstanceType<typeof Brain>> {
|
|||||||
baseSystemPrompt:
|
baseSystemPrompt:
|
||||||
"Test personality: night owl, introverted, studies at midnight.",
|
"Test personality: night owl, introverted, studies at midnight.",
|
||||||
};
|
};
|
||||||
return new Brain(db, space, brainbase);
|
return new Brain(db, space, brainbase, false, embeddingProvider as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -149,6 +193,7 @@ beforeEach(() => {
|
|||||||
customMonthlyDays = null;
|
customMonthlyDays = null;
|
||||||
customDailySlots = null;
|
customDailySlots = null;
|
||||||
customAvailability = null;
|
customAvailability = null;
|
||||||
|
chatResponses = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Brain.createDailySchedule", () => {
|
describe("Brain.createDailySchedule", () => {
|
||||||
@@ -468,3 +513,323 @@ describe("Brain.createDebug", () => {
|
|||||||
expect(facts).toHaveLength(0);
|
expect(facts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const NOOP_EMBEDDING_PROVIDER = {
|
||||||
|
model: "test-embed",
|
||||||
|
dimensions: 4,
|
||||||
|
async embed(_input: string): Promise<number[]> {
|
||||||
|
return [0, 0, 0, 0];
|
||||||
|
},
|
||||||
|
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||||
|
return inputs.map(() => [0, 0, 0, 0]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCORING_EMBEDDING_PROVIDER = {
|
||||||
|
model: "test-embed-scoring",
|
||||||
|
dimensions: 4,
|
||||||
|
async embed(input: string): Promise<number[]> {
|
||||||
|
if (input.includes("coffee")) return [1, 0, 0, 0];
|
||||||
|
if (input.includes("pizza")) return [0, 1, 0, 0];
|
||||||
|
return [0, 0, 1, 0];
|
||||||
|
},
|
||||||
|
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||||
|
return inputs.map((s) => {
|
||||||
|
if (s.includes("coffee")) return [1, 0, 0, 0];
|
||||||
|
if (s.includes("pizza")) return [0, 1, 0, 0];
|
||||||
|
return [0, 0, 1, 0];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Brain.sendMessage — translateMessageHistory helper", () => {
|
||||||
|
test("SM1: translateMessageHistory produces the documented format with persona label and timestamps", async () => {
|
||||||
|
const { translateMessageHistory } = await import("./messageHistory");
|
||||||
|
const t1 = new Date(2026, 5, 10, 9, 30, 0);
|
||||||
|
const t2 = new Date(2026, 5, 10, 9, 31, 0);
|
||||||
|
const t3 = new Date(2026, 5, 10, 9, 32, 0);
|
||||||
|
const out = translateMessageHistory("Mika", [
|
||||||
|
{ sender: "persona", time: t1, content: "다음에 보자" },
|
||||||
|
{ sender: "user", time: t2, content: "그래" },
|
||||||
|
{ sender: "user", time: t3, content: "지금 뭐해?" },
|
||||||
|
]);
|
||||||
|
const lines = out.split("\n");
|
||||||
|
expect(lines).toHaveLength(3);
|
||||||
|
expect(lines[0]!.startsWith("Mika@")).toBe(true);
|
||||||
|
expect(lines[0]!.endsWith(": 다음에 보자")).toBe(true);
|
||||||
|
expect(lines[1]!.startsWith("사용자@")).toBe(true);
|
||||||
|
expect(lines[1]!.endsWith(": 그래")).toBe(true);
|
||||||
|
expect(lines[2]!.startsWith("사용자@")).toBe(true);
|
||||||
|
expect(lines[2]!.endsWith(": 지금 뭐해?")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM2: translateMessageHistory returns empty string for empty history", async () => {
|
||||||
|
const { translateMessageHistory } = await import("./messageHistory");
|
||||||
|
expect(translateMessageHistory("Mika", [])).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Brain.sendMessage — tool-calling flow", () => {
|
||||||
|
test("SM3: sendMessage returns the LLM's final text when no tools are called", async () => {
|
||||||
|
const brain = await makeBrain();
|
||||||
|
chatResponses = [
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_r1",
|
||||||
|
name: "addReplyMessage",
|
||||||
|
arguments: JSON.stringify({ content: "안녕!" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "text", text: "(end)" },
|
||||||
|
];
|
||||||
|
const out = await brain.sendMessage(
|
||||||
|
[{ sender: "user", time: new Date(2026, 5, 10, 9, 0, 0), content: "안녕" }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(out).toEqual(["안녕!"]);
|
||||||
|
const toolsCall = llmCalls.find(
|
||||||
|
(c) => Array.isArray(c.options.tools) && c.options.tools.length > 0,
|
||||||
|
);
|
||||||
|
expect(toolsCall).toBeDefined();
|
||||||
|
const toolNames = (
|
||||||
|
toolsCall!.options.tools as Array<{
|
||||||
|
function: { name: string };
|
||||||
|
}>
|
||||||
|
).map((t) => t.function.name);
|
||||||
|
expect(toolNames).toContain("addReplyMessage");
|
||||||
|
expect(toolNames).toContain("searchIdentityDB");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM4: sendMessage accumulates addReplyMessage tool calls and returns them in order", async () => {
|
||||||
|
const brain = await makeBrain();
|
||||||
|
chatResponses = [
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_1",
|
||||||
|
name: "addReplyMessage",
|
||||||
|
arguments: JSON.stringify({ content: "어." }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_2",
|
||||||
|
name: "addReplyMessage",
|
||||||
|
arguments: JSON.stringify({ content: "왜불러" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "text", text: "(end)" },
|
||||||
|
];
|
||||||
|
const out = await brain.sendMessage(
|
||||||
|
[{ sender: "user", time: new Date(2026, 5, 10, 9, 0, 0), content: "야" }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(out).toEqual(["어.", "왜불러"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM5: sendMessage feeds searchIdentityDB tool result back to the LLM", async () => {
|
||||||
|
const brain = await makeBrain(SCORING_EMBEDDING_PROVIDER);
|
||||||
|
const fact = await brain.db.addFact({
|
||||||
|
spaceName: brain.space.name,
|
||||||
|
statement: "사용자는 커피를 좋아한다",
|
||||||
|
summary: "user loves coffee",
|
||||||
|
source: "test",
|
||||||
|
confidence: 1.0,
|
||||||
|
topics: [
|
||||||
|
{ name: "사용자", category: "entity", granularity: "concrete" },
|
||||||
|
{ name: "커피", category: "concept", granularity: "abstract" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await brain.indexFactEmbeddingFor(fact);
|
||||||
|
|
||||||
|
chatResponses = [
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_s",
|
||||||
|
name: "searchIdentityDB",
|
||||||
|
arguments: JSON.stringify({ query: "커피" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_r",
|
||||||
|
name: "addReplyMessage",
|
||||||
|
arguments: JSON.stringify({ content: "커피 좋아하잖아" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "text", text: "(end)" },
|
||||||
|
];
|
||||||
|
const out = await brain.sendMessage(
|
||||||
|
[{ sender: "user", time: new Date(2026, 5, 10, 9, 0, 0), content: "뭐 좋아하는지 알아?" }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(out).toEqual(["커피 좋아하잖아"]);
|
||||||
|
|
||||||
|
const toolsCalls = llmCalls.filter(
|
||||||
|
(c) => Array.isArray(c.options.tools) && c.options.tools.length > 0,
|
||||||
|
);
|
||||||
|
expect(toolsCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const messages = toolsCalls[1]!.options.messages as Array<{
|
||||||
|
role: string;
|
||||||
|
content?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
}>;
|
||||||
|
const toolMsg = messages.find(
|
||||||
|
(m) => m.role === "tool" && m.toolCallId === "call_s",
|
||||||
|
);
|
||||||
|
expect(toolMsg).toBeDefined();
|
||||||
|
expect(toolMsg!.content).toContain("커피");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM6: sendMessage with empty history still works and includes translated user messages", async () => {
|
||||||
|
const brain = await makeBrain();
|
||||||
|
chatResponses = [
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_r1",
|
||||||
|
name: "addReplyMessage",
|
||||||
|
arguments: JSON.stringify({ content: "first" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "text", text: "(end)" },
|
||||||
|
];
|
||||||
|
const out = await brain.sendMessage(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sender: "user",
|
||||||
|
time: new Date(2026, 5, 10, 9, 0, 0),
|
||||||
|
content: "하이",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(out).toEqual(["first"]);
|
||||||
|
const toolsCall = llmCalls.find(
|
||||||
|
(c) => Array.isArray(c.options.tools) && c.options.tools.length > 0,
|
||||||
|
);
|
||||||
|
expect(toolsCall).toBeDefined();
|
||||||
|
const userMsg = (
|
||||||
|
toolsCall!.options.messages as Array<{ role: string; content?: string }>
|
||||||
|
).find((m) => m.role === "user");
|
||||||
|
expect(userMsg!.content).toContain("사용자@");
|
||||||
|
expect(userMsg!.content).toContain("하이");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM7: createDailySchedule auto-indexes the new fact so it is searchable via the provider", async () => {
|
||||||
|
const brain = await makeBrain(SCORING_EMBEDDING_PROVIDER);
|
||||||
|
const today = new Date(2026, 5, 5);
|
||||||
|
const tomorrow = new Date(2026, 5, 6);
|
||||||
|
const tomorrowKey = formatDateKey(tomorrow);
|
||||||
|
|
||||||
|
customDailySlots = build48Slots();
|
||||||
|
await brain.createDailySchedule(today, "msg");
|
||||||
|
|
||||||
|
const hits = await brain.db.searchFacts({
|
||||||
|
spaceName: brain.space.name,
|
||||||
|
query: "slot-0",
|
||||||
|
provider: SCORING_EMBEDDING_PROVIDER as never,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(hits.length).toBeGreaterThan(0);
|
||||||
|
const matched = hits.find((h) =>
|
||||||
|
h.statement.includes(`"activity":"slot-0"`),
|
||||||
|
);
|
||||||
|
expect(matched).toBeDefined();
|
||||||
|
|
||||||
|
const topicFacts = await brain.db.getTopicFacts(
|
||||||
|
`daily-schedule:${tomorrowKey}`,
|
||||||
|
{ spaceName: brain.space.name },
|
||||||
|
);
|
||||||
|
expect(topicFacts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM8: sendMessage no longer calls indexFactEmbeddings on every turn (uses per-fact init)", async () => {
|
||||||
|
const brain = await makeBrain(NOOP_EMBEDDING_PROVIDER);
|
||||||
|
let embedManyCalls = 0;
|
||||||
|
const trackingProvider = {
|
||||||
|
model: "track-embed",
|
||||||
|
dimensions: 4,
|
||||||
|
async embed(_input: string): Promise<number[]> {
|
||||||
|
return [0, 0, 0, 0];
|
||||||
|
},
|
||||||
|
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||||
|
embedManyCalls += 1;
|
||||||
|
return inputs.map(() => [0, 0, 0, 0]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(brain, "embeddingProvider", {
|
||||||
|
value: trackingProvider,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
chatResponses = [
|
||||||
|
{
|
||||||
|
kind: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_r1",
|
||||||
|
name: "addReplyMessage",
|
||||||
|
arguments: JSON.stringify({ content: "ok" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "text", text: "(end)" },
|
||||||
|
];
|
||||||
|
await brain.sendMessage(
|
||||||
|
[{ sender: "user", time: new Date(2026, 5, 10, 9, 0, 0), content: "hi" }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(embedManyCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SM9: initializeEmbeddings backfills missing embeddings for facts added out-of-band", async () => {
|
||||||
|
const brain = await makeBrain(SCORING_EMBEDDING_PROVIDER);
|
||||||
|
await brain.db.addFact({
|
||||||
|
spaceName: brain.space.name,
|
||||||
|
statement: "사용자는 피자를 좋아한다",
|
||||||
|
summary: "user loves pizza",
|
||||||
|
source: "test",
|
||||||
|
confidence: 1.0,
|
||||||
|
topics: [
|
||||||
|
{ name: "사용자", category: "entity", granularity: "concrete" },
|
||||||
|
{ name: "피자", category: "concept", granularity: "abstract" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let preInitHits = await brain.db.searchFacts({
|
||||||
|
spaceName: brain.space.name,
|
||||||
|
query: "피자",
|
||||||
|
provider: SCORING_EMBEDDING_PROVIDER as never,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(preInitHits).toHaveLength(0);
|
||||||
|
|
||||||
|
await brain.initializeEmbeddings();
|
||||||
|
|
||||||
|
const postInitHits = await brain.db.searchFacts({
|
||||||
|
spaceName: brain.space.name,
|
||||||
|
query: "피자",
|
||||||
|
provider: SCORING_EMBEDDING_PROVIDER as never,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(postInitHits.length).toBeGreaterThan(0);
|
||||||
|
expect(postInitHits[0]!.statement).toContain("피자");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { config } from "@/config";
|
import { config } from "@/config";
|
||||||
import { IdentityDB, type ExtractedFact, type Space } from "identitydb";
|
import {
|
||||||
|
IdentityDB,
|
||||||
|
type EmbeddingProvider,
|
||||||
|
type ExtractedFact,
|
||||||
|
type Space,
|
||||||
|
} from "identitydb";
|
||||||
import { llm } from "@/openrouter";
|
import { llm } from "@/openrouter";
|
||||||
|
import { OpenRouterEmbeddingProvider } from "@/openrouter/embedding";
|
||||||
import { loadPrompt } from "@/openrouter/promptLoader";
|
import { loadPrompt } from "@/openrouter/promptLoader";
|
||||||
import {
|
import {
|
||||||
availabilitySchema,
|
availabilitySchema,
|
||||||
dailyScheduleSchema,
|
dailyScheduleSchema,
|
||||||
monthlyScheduleSchema,
|
monthlyScheduleSchema,
|
||||||
type Availability,
|
|
||||||
type AvailabilityWindows,
|
type AvailabilityWindows,
|
||||||
type DailySchedule,
|
type DailySchedule,
|
||||||
type MonthlySchedule,
|
type MonthlySchedule,
|
||||||
@@ -15,6 +20,10 @@ import {
|
|||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { factExtractor } from "./factExtractor";
|
import { factExtractor } from "./factExtractor";
|
||||||
import { BrainDBManager, brainManager, type BrainItem } from "./manager";
|
import { BrainDBManager, brainManager, type BrainItem } from "./manager";
|
||||||
|
import {
|
||||||
|
translateMessageHistory,
|
||||||
|
type MessageHistoryEntry,
|
||||||
|
} from "./messageHistory";
|
||||||
import {
|
import {
|
||||||
formatDateKey,
|
formatDateKey,
|
||||||
formatMonthKey,
|
formatMonthKey,
|
||||||
@@ -23,6 +32,12 @@ import {
|
|||||||
pad2,
|
pad2,
|
||||||
} from "./schedule";
|
} from "./schedule";
|
||||||
import { BadRequestResponseError } from "@openrouter/sdk/models/errors";
|
import { BadRequestResponseError } from "@openrouter/sdk/models/errors";
|
||||||
|
import type {
|
||||||
|
ChatAssistantMessage,
|
||||||
|
ChatChoice,
|
||||||
|
ChatFunctionTool,
|
||||||
|
ChatMessages,
|
||||||
|
} from "@openrouter/sdk/models";
|
||||||
|
|
||||||
export interface DebugOptions {
|
export interface DebugOptions {
|
||||||
personality: string;
|
personality: string;
|
||||||
@@ -43,13 +58,18 @@ export interface BrainCreateResult {
|
|||||||
|
|
||||||
export class Brain {
|
export class Brain {
|
||||||
private availabilityCache: Map<string, AvailabilityWindows> = new Map();
|
private availabilityCache: Map<string, AvailabilityWindows> = new Map();
|
||||||
|
private embeddingProvider: EmbeddingProvider;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public db: IdentityDB,
|
public db: IdentityDB,
|
||||||
public space: Space,
|
public space: Space,
|
||||||
public brainbase: BrainItem,
|
public brainbase: BrainItem,
|
||||||
public debug: boolean = false,
|
public debug: boolean = false,
|
||||||
) {}
|
embeddingProvider?: EmbeddingProvider,
|
||||||
|
) {
|
||||||
|
this.embeddingProvider =
|
||||||
|
embeddingProvider ?? new OpenRouterEmbeddingProvider();
|
||||||
|
}
|
||||||
|
|
||||||
async createDailySchedule(
|
async createDailySchedule(
|
||||||
datetime: Date,
|
datetime: Date,
|
||||||
@@ -83,7 +103,7 @@ export class Brain {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.debug) {
|
if (!this.debug) {
|
||||||
await this.db.addFact({
|
const fact = await this.db.addFact({
|
||||||
spaceName: this.space.name,
|
spaceName: this.space.name,
|
||||||
statement: JSON.stringify(schedule),
|
statement: JSON.stringify(schedule),
|
||||||
summary: `Daily schedule for ${dateKey} (${schedule.items.length} slots)`,
|
summary: `Daily schedule for ${dateKey} (${schedule.items.length} slots)`,
|
||||||
@@ -110,6 +130,7 @@ export class Brain {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await this.indexFactEmbeddingFor(fact);
|
||||||
}
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
@@ -153,7 +174,7 @@ export class Brain {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.debug) {
|
if (!this.debug) {
|
||||||
await this.db.addFact({
|
const fact = await this.db.addFact({
|
||||||
spaceName: this.space.name,
|
spaceName: this.space.name,
|
||||||
statement: JSON.stringify(schedule),
|
statement: JSON.stringify(schedule),
|
||||||
summary: `Monthly schedule for ${monthKey} (${schedule.items.length} days)`,
|
summary: `Monthly schedule for ${monthKey} (${schedule.items.length} days)`,
|
||||||
@@ -180,6 +201,7 @@ export class Brain {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await this.indexFactEmbeddingFor(fact);
|
||||||
}
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
@@ -251,6 +273,232 @@ export class Brain {
|
|||||||
this.availabilityCache.clear();
|
this.availabilityCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embeds a single fact in the embedding table. Called automatically by
|
||||||
|
* Brain methods that add facts (createDailySchedule, createMonthlySchedule,
|
||||||
|
* Brain.create). Callers who add facts via `db.addFact` directly should
|
||||||
|
* invoke this so the LLM can recall the fact via `searchIdentityDB`. A
|
||||||
|
* no-op in debug mode (where there is no persisted state).
|
||||||
|
*/
|
||||||
|
async indexFactEmbeddingFor(fact: { id: string }): Promise<void> {
|
||||||
|
if (this.debug) return;
|
||||||
|
try {
|
||||||
|
await this.db.indexFactEmbedding(fact.id, {
|
||||||
|
spaceName: this.space.name,
|
||||||
|
provider: this.embeddingProvider,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn(`indexFactEmbeddingFor(${fact.id}) failed: ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills embeddings for every fact in this brain's space. Intended
|
||||||
|
* for `Brain.create` and `Brain.load` — runs once at initialization so
|
||||||
|
* facts added by older code paths (or out-of-band) become searchable.
|
||||||
|
* No-op in debug mode and when the space has no facts.
|
||||||
|
*/
|
||||||
|
async initializeEmbeddings(): Promise<void> {
|
||||||
|
if (this.debug) return;
|
||||||
|
try {
|
||||||
|
await this.db.indexFactEmbeddings({
|
||||||
|
spaceName: this.space.name,
|
||||||
|
provider: this.embeddingProvider,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn(`initializeEmbeddings failed: ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
history: ReadonlyArray<MessageHistoryEntry>,
|
||||||
|
newMessages: ReadonlyArray<MessageHistoryEntry>,
|
||||||
|
options: { now?: Date; maxSteps?: number } = {},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const maxSteps = options.maxSteps ?? 8;
|
||||||
|
|
||||||
|
const replyMessages: string[] = [];
|
||||||
|
const tools: ChatFunctionTool[] = buildSendMessageTools();
|
||||||
|
const historyBlock = translateMessageHistory(
|
||||||
|
this.brainbase.displayName,
|
||||||
|
history,
|
||||||
|
);
|
||||||
|
const newBlock = translateMessageHistory(
|
||||||
|
this.brainbase.displayName,
|
||||||
|
newMessages,
|
||||||
|
);
|
||||||
|
const memoryBlock = await this.buildMemoryBlock();
|
||||||
|
const scheduleBlock = await this.buildScheduleBlock(now);
|
||||||
|
const datetimeBlock = formatDatetime(now);
|
||||||
|
|
||||||
|
const instruction = await loadPrompt("SEND_MESSAGE");
|
||||||
|
const userPrompt = [
|
||||||
|
`Current date and time: ${datetimeBlock}`,
|
||||||
|
scheduleBlock,
|
||||||
|
memoryBlock,
|
||||||
|
`Conversation so far:`,
|
||||||
|
historyBlock.length > 0 ? historyBlock : "(no prior messages)",
|
||||||
|
`New user message(s) to which you must reply:`,
|
||||||
|
newBlock.length > 0 ? newBlock : "(none — open turn)",
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
const messages: ChatMessages[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: userPrompt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let step = 0; step < maxSteps; step += 1) {
|
||||||
|
let choice: ChatChoice;
|
||||||
|
try {
|
||||||
|
choice = await llm.chatWithTools(llm.models.conversation, {
|
||||||
|
instruction: `${this.brainbase.baseSystemPrompt}\n\n${instruction}`,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`sendMessage: LLM call failed at step ${step}: ${reason}`);
|
||||||
|
return replyMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage = choice.message;
|
||||||
|
const toolCalls = assistantMessage.toolCalls ?? [];
|
||||||
|
const hasContent =
|
||||||
|
typeof assistantMessage.content === "string" &&
|
||||||
|
assistantMessage.content.length > 0;
|
||||||
|
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
return replyMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(stripAssistantForHistory(assistantMessage));
|
||||||
|
|
||||||
|
for (const call of toolCalls) {
|
||||||
|
if (call.function.name === "addReplyMessage") {
|
||||||
|
const content = parseAddReplyMessageArguments(
|
||||||
|
call.function.arguments,
|
||||||
|
);
|
||||||
|
if (content !== null) replyMessages.push(content);
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
toolCallId: call.id,
|
||||||
|
content:
|
||||||
|
content === null
|
||||||
|
? JSON.stringify({ ok: false, error: "invalid arguments" })
|
||||||
|
: JSON.stringify({ ok: true, index: replyMessages.length - 1 }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (call.function.name === "searchIdentityDB") {
|
||||||
|
const result = await this.executeSearchTool(call.function.arguments);
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
toolCallId: call.id,
|
||||||
|
content: result,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
toolCallId: call.id,
|
||||||
|
content: JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: `Unknown tool: ${call.function.name}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasContent &&
|
||||||
|
toolCalls.every((c) => c.function.name === "searchIdentityDB")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`sendMessage: reached maxSteps (${maxSteps}) without final reply`,
|
||||||
|
);
|
||||||
|
return replyMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildMemoryBlock(): Promise<string> {
|
||||||
|
const facts = await this.getHistoryFacts();
|
||||||
|
return `Known facts about the persona and the user:\n${facts || "(none indexed)"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildScheduleBlock(now: Date): Promise<string> {
|
||||||
|
const days: { label: string; date: Date }[] = [
|
||||||
|
{
|
||||||
|
label: "Yesterday",
|
||||||
|
date: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1),
|
||||||
|
},
|
||||||
|
{ label: "Today", date: now },
|
||||||
|
{
|
||||||
|
label: "Tomorrow",
|
||||||
|
date: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const blocks: string[] = [];
|
||||||
|
for (const { label, date } of days) {
|
||||||
|
const key = formatDateKey(date);
|
||||||
|
const summary = await this.getDailyScheduleSummary(key);
|
||||||
|
blocks.push(
|
||||||
|
`${label} (${key}): ${summary ?? "(no daily schedule on file)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `Schedule context:\n${blocks.join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDailyScheduleSummary(
|
||||||
|
dateKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (this.debug) return null;
|
||||||
|
try {
|
||||||
|
const facts = await this.db.getTopicFacts(`daily-schedule:${dateKey}`, {
|
||||||
|
spaceName: this.space.name,
|
||||||
|
});
|
||||||
|
if (facts.length === 0) return null;
|
||||||
|
const schedule = JSON.parse(facts[0]!.statement) as DailySchedule;
|
||||||
|
const first = schedule.items[0];
|
||||||
|
const last = schedule.items[schedule.items.length - 1];
|
||||||
|
if (!first || !last) return null;
|
||||||
|
const total = schedule.items.length;
|
||||||
|
return `starts ${first.activity}@${first.start}, ends ${last.activity}@${last.end} (${total} slots)`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeSearchTool(argumentsJson: string): Promise<string> {
|
||||||
|
const query = parseSearchArguments(argumentsJson);
|
||||||
|
if (!query) {
|
||||||
|
return JSON.stringify({ ok: false, error: "missing query" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hits = await this.db.searchFacts({
|
||||||
|
spaceName: this.space.name,
|
||||||
|
query,
|
||||||
|
provider: this.embeddingProvider,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const compact = hits.map((hit) => ({
|
||||||
|
statement: hit.statement,
|
||||||
|
summary: hit.summary,
|
||||||
|
score: hit.score,
|
||||||
|
}));
|
||||||
|
return JSON.stringify({ ok: true, hits: compact });
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
return JSON.stringify({ ok: false, error: reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getMonthlySummaryForDay(target: Date): Promise<string | null> {
|
private async getMonthlySummaryForDay(target: Date): Promise<string | null> {
|
||||||
if (this.debug) return null;
|
if (this.debug) return null;
|
||||||
try {
|
try {
|
||||||
@@ -293,12 +541,19 @@ export class Brain {
|
|||||||
static async create(
|
static async create(
|
||||||
displayName: string,
|
displayName: string,
|
||||||
seed: string,
|
seed: string,
|
||||||
options: { dbPath?: string; braindbPath?: string; debug?: boolean } = {},
|
options: {
|
||||||
|
dbPath?: string;
|
||||||
|
braindbPath?: string;
|
||||||
|
debug?: boolean;
|
||||||
|
embeddingProvider?: EmbeddingProvider;
|
||||||
|
} = {},
|
||||||
): Promise<BrainCreateResult | null> {
|
): Promise<BrainCreateResult | null> {
|
||||||
const dbPath = options.dbPath ?? config.dbPath;
|
const dbPath = options.dbPath ?? config.dbPath;
|
||||||
const manager = options.braindbPath
|
const manager = options.braindbPath
|
||||||
? new BrainDBManager(options.braindbPath)
|
? new BrainDBManager(options.braindbPath)
|
||||||
: brainManager;
|
: brainManager;
|
||||||
|
const embeddingProvider =
|
||||||
|
options.embeddingProvider ?? new OpenRouterEmbeddingProvider();
|
||||||
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, {
|
||||||
@@ -338,7 +593,7 @@ export class Brain {
|
|||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
extractedFacts = await factExtractor.extract(description);
|
extractedFacts = await factExtractor.extract(description);
|
||||||
for (const fact of extractedFacts) {
|
for (const fact of extractedFacts) {
|
||||||
await db.addFact({
|
const created = await db.addFact({
|
||||||
spaceName,
|
spaceName,
|
||||||
statement: fact.statement ?? description,
|
statement: fact.statement ?? description,
|
||||||
summary: fact.summary,
|
summary: fact.summary,
|
||||||
@@ -347,10 +602,15 @@ export class Brain {
|
|||||||
topics: fact.topics,
|
topics: fact.topics,
|
||||||
metadata: fact.metadata,
|
metadata: fact.metadata,
|
||||||
});
|
});
|
||||||
|
await db.indexFactEmbedding(created.id, {
|
||||||
|
spaceName,
|
||||||
|
provider: embeddingProvider,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await db.ingestStatements(description, {
|
await db.ingestStatements(description, {
|
||||||
extractor: factExtractor,
|
extractor: factExtractor,
|
||||||
|
embeddingProvider,
|
||||||
spaceName,
|
spaceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -363,7 +623,7 @@ export class Brain {
|
|||||||
};
|
};
|
||||||
await manager.saveBrain(brainId, brainbase);
|
await manager.saveBrain(brainId, brainbase);
|
||||||
|
|
||||||
const brain = new Brain(db, space, brainbase);
|
const brain = new Brain(db, space, brainbase, false, embeddingProvider);
|
||||||
return { brain, description, baseSystemPrompt, extractedFacts };
|
return { brain, description, baseSystemPrompt, extractedFacts };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error);
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
@@ -384,7 +644,9 @@ export class Brain {
|
|||||||
const space = await db.getSpaceByName(brain.spaceName);
|
const space = await db.getSpaceByName(brain.spaceName);
|
||||||
if (!space) return null;
|
if (!space) return null;
|
||||||
|
|
||||||
return new Brain(db, space, brain);
|
const brainInstance = new Brain(db, space, brain);
|
||||||
|
await brainInstance.initializeEmbeddings();
|
||||||
|
return brainInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createDebug(options: DebugOptions): Promise<Brain> {
|
static async createDebug(options: DebugOptions): Promise<Brain> {
|
||||||
@@ -408,3 +670,85 @@ export class Brain {
|
|||||||
return new Brain(db, space, brainbase, true);
|
return new Brain(db, space, brainbase, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDatetime(now: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(
|
||||||
|
now.getHours(),
|
||||||
|
)}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSendMessageTools(): ChatFunctionTool[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "addReplyMessage",
|
||||||
|
description:
|
||||||
|
"Append one chat bubble to the reply stream. Call once per bubble you want to send. Do not call when you are done — just return text without tool calls.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
content: { type: "string", description: "The bubble text." },
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "searchIdentityDB",
|
||||||
|
description:
|
||||||
|
"Semantic search over the long-term memory of facts about the persona and the user. Returns the most relevant stored statements for a natural-language query.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Natural-language query describing the fact you want to recall.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAddReplyMessageArguments(json: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as { content?: unknown };
|
||||||
|
if (typeof parsed.content === "string" && parsed.content.length > 0) {
|
||||||
|
return parsed.content;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSearchArguments(json: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as { query?: unknown };
|
||||||
|
if (typeof parsed.query === "string" && parsed.query.trim().length > 0) {
|
||||||
|
return parsed.query;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAssistantForHistory(
|
||||||
|
message: ChatAssistantMessage,
|
||||||
|
): ChatAssistantMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: message.content ?? null,
|
||||||
|
toolCalls: message.toolCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ mock.module("@/config", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mock.module("@/openrouter/embedding", () => ({
|
||||||
|
OpenRouterEmbeddingProvider: class {
|
||||||
|
model = "test-embed";
|
||||||
|
dimensions = 4;
|
||||||
|
async embed(_input: string): Promise<number[]> {
|
||||||
|
return [0, 0, 0, 0];
|
||||||
|
}
|
||||||
|
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||||
|
return inputs.map(() => [0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const { runDebugBrainInit } = await import("./brain");
|
const { runDebugBrainInit } = await import("./brain");
|
||||||
const { Brain: ProdBrain } = await import("@/brain");
|
const { Brain: ProdBrain } = await import("@/brain");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user