From e281b8a38f6c2c74d363ac5daf50a0099cc59326 Mon Sep 17 00:00:00 2001 From: p-sw Date: Wed, 10 Jun 2026 23:55:18 +0900 Subject: [PATCH] feat: implement sendMessage --- src/brain/index.test.ts | 369 ++++++++++++++++++++++++++++++- src/brain/index.ts | 362 +++++++++++++++++++++++++++++- src/commands/debug/brain.test.ts | 13 ++ 3 files changed, 733 insertions(+), 11 deletions(-) diff --git a/src/brain/index.test.ts b/src/brain/index.test.ts index bd0e729..8b12b6c 100644 --- a/src/brain/index.test.ts +++ b/src/brain/index.test.ts @@ -24,6 +24,21 @@ let customAvailability: Array<{ status: string; }> | 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<{ start: string; end: string; @@ -95,6 +110,32 @@ const mockCall = mock(async (model: unknown, options: any): Promise => { if (options.jsonSchemaName === "availability") { 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}`); }); @@ -102,6 +143,7 @@ mock.module("@/openrouter", () => ({ llm: { models: { conversation: "test-conv", identity: "test-id" }, call: mockCall, + chatWithTools: mockCall, }, })); @@ -126,7 +168,9 @@ beforeAll(async () => { afterAll(async () => {}); -async function makeBrain(): Promise> { +async function makeBrain( + embeddingProvider: unknown = NOOP_EMBEDDING_PROVIDER, +): Promise> { const db = await IdentityDB.connect({ client: "sqlite", filename: ":memory:", @@ -141,7 +185,7 @@ async function makeBrain(): Promise> { baseSystemPrompt: "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(() => { @@ -149,6 +193,7 @@ beforeEach(() => { customMonthlyDays = null; customDailySlots = null; customAvailability = null; + chatResponses = []; }); describe("Brain.createDailySchedule", () => { @@ -468,3 +513,323 @@ describe("Brain.createDebug", () => { expect(facts).toHaveLength(0); }); }); + +const NOOP_EMBEDDING_PROVIDER = { + model: "test-embed", + dimensions: 4, + async embed(_input: string): Promise { + return [0, 0, 0, 0]; + }, + async embedMany(inputs: string[]): Promise { + return inputs.map(() => [0, 0, 0, 0]); + }, +}; + +const SCORING_EMBEDDING_PROVIDER = { + model: "test-embed-scoring", + dimensions: 4, + async embed(input: string): Promise { + 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 { + 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 { + return [0, 0, 0, 0]; + }, + async embedMany(inputs: string[]): Promise { + 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("피자"); + }); +}); diff --git a/src/brain/index.ts b/src/brain/index.ts index 2d75302..ef45f06 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -1,13 +1,18 @@ import { randomUUID } from "node:crypto"; 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 { OpenRouterEmbeddingProvider } from "@/openrouter/embedding"; import { loadPrompt } from "@/openrouter/promptLoader"; import { availabilitySchema, dailyScheduleSchema, monthlyScheduleSchema, - type Availability, type AvailabilityWindows, type DailySchedule, type MonthlySchedule, @@ -15,6 +20,10 @@ import { import { logger } from "@/utils/logger"; import { factExtractor } from "./factExtractor"; import { BrainDBManager, brainManager, type BrainItem } from "./manager"; +import { + translateMessageHistory, + type MessageHistoryEntry, +} from "./messageHistory"; import { formatDateKey, formatMonthKey, @@ -23,6 +32,12 @@ import { pad2, } from "./schedule"; import { BadRequestResponseError } from "@openrouter/sdk/models/errors"; +import type { + ChatAssistantMessage, + ChatChoice, + ChatFunctionTool, + ChatMessages, +} from "@openrouter/sdk/models"; export interface DebugOptions { personality: string; @@ -43,13 +58,18 @@ export interface BrainCreateResult { export class Brain { private availabilityCache: Map = new Map(); + private embeddingProvider: EmbeddingProvider; constructor( public db: IdentityDB, public space: Space, public brainbase: BrainItem, public debug: boolean = false, - ) {} + embeddingProvider?: EmbeddingProvider, + ) { + this.embeddingProvider = + embeddingProvider ?? new OpenRouterEmbeddingProvider(); + } async createDailySchedule( datetime: Date, @@ -83,7 +103,7 @@ export class Brain { }); if (!this.debug) { - await this.db.addFact({ + const fact = await this.db.addFact({ spaceName: this.space.name, statement: JSON.stringify(schedule), summary: `Daily schedule for ${dateKey} (${schedule.items.length} slots)`, @@ -110,6 +130,7 @@ export class Brain { }, ], }); + await this.indexFactEmbeddingFor(fact); } return schedule; @@ -153,7 +174,7 @@ export class Brain { }); if (!this.debug) { - await this.db.addFact({ + const fact = await this.db.addFact({ spaceName: this.space.name, statement: JSON.stringify(schedule), summary: `Monthly schedule for ${monthKey} (${schedule.items.length} days)`, @@ -180,6 +201,7 @@ export class Brain { }, ], }); + await this.indexFactEmbeddingFor(fact); } return schedule; @@ -251,6 +273,232 @@ export class Brain { 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 { + 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 { + 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, + newMessages: ReadonlyArray, + options: { now?: Date; maxSteps?: number } = {}, + ): Promise { + 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 { + const facts = await this.getHistoryFacts(); + return `Known facts about the persona and the user:\n${facts || "(none indexed)"}`; + } + + private async buildScheduleBlock(now: Date): Promise { + 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 { + 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 { + 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 { if (this.debug) return null; try { @@ -293,12 +541,19 @@ export class Brain { static async create( displayName: string, seed: string, - options: { dbPath?: string; braindbPath?: string; debug?: boolean } = {}, + options: { + dbPath?: string; + braindbPath?: string; + debug?: boolean; + embeddingProvider?: EmbeddingProvider; + } = {}, ): Promise { const dbPath = options.dbPath ?? config.dbPath; const manager = options.braindbPath ? new BrainDBManager(options.braindbPath) : brainManager; + const embeddingProvider = + options.embeddingProvider ?? new OpenRouterEmbeddingProvider(); try { const personaInitInstruction = await loadPrompt("PERSONA_INIT"); const description = await llm.call(llm.models.identity, { @@ -338,7 +593,7 @@ export class Brain { if (options.debug) { extractedFacts = await factExtractor.extract(description); for (const fact of extractedFacts) { - await db.addFact({ + const created = await db.addFact({ spaceName, statement: fact.statement ?? description, summary: fact.summary, @@ -347,10 +602,15 @@ export class Brain { topics: fact.topics, metadata: fact.metadata, }); + await db.indexFactEmbedding(created.id, { + spaceName, + provider: embeddingProvider, + }); } } else { await db.ingestStatements(description, { extractor: factExtractor, + embeddingProvider, spaceName, }); } @@ -363,7 +623,7 @@ export class Brain { }; 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 }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -384,7 +644,9 @@ export class Brain { const space = await db.getSpaceByName(brain.spaceName); 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 { @@ -408,3 +670,85 @@ export class Brain { 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, + }; +} diff --git a/src/commands/debug/brain.test.ts b/src/commands/debug/brain.test.ts index c4c697c..ef55c50 100644 --- a/src/commands/debug/brain.test.ts +++ b/src/commands/debug/brain.test.ts @@ -87,6 +87,19 @@ mock.module("@/config", () => ({ }, })); +mock.module("@/openrouter/embedding", () => ({ + OpenRouterEmbeddingProvider: class { + model = "test-embed"; + dimensions = 4; + async embed(_input: string): Promise { + return [0, 0, 0, 0]; + } + async embedMany(inputs: string[]): Promise { + return inputs.map(() => [0, 0, 0, 0]); + } + }, +})); + const { runDebugBrainInit } = await import("./brain"); const { Brain: ProdBrain } = await import("@/brain");