feat: implement sendMessage

This commit is contained in:
2026-06-10 23:55:18 +09:00
parent c9865536a3
commit e281b8a38f
3 changed files with 733 additions and 11 deletions

View File

@@ -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<string, AvailabilityWindows> = 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<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> {
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<BrainCreateResult | null> {
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<string>(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<Brain> {
@@ -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,
};
}