import type { AdapterContext, EmbeddingProvider, SemanticMemoryForgetRequest, SemanticMemoryForgetResponse, SemanticMemoryProvider, SemanticMemoryPurgeHistoryRequest, SemanticMemoryRecallRequest, SemanticMemoryResponse, SemanticMemoryResult, SemanticMemorySaveRequest, } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { cosineSimilarity } from "./embedding-similarity.js"; import { OpenAiCompatibleEmbeddingProvider } from "./openai-compatible-embeddings.js"; import { isPrivateOpenAiCompatibleHostname, normalizeOpenAiCompatibleBaseUrl, } from "./openai-compatible-url.js"; export const BUILTIN_MEMORY_PROVIDER_ID = "builtin"; /** Newest items ranked per recall. Older items stay stored but are not scanned. */ const MAX_RECALL_CANDIDATES = 2_000; /** Below this cosine similarity an item is unrelated to the query and is not recalled. */ const MIN_RECALL_SIMILARITY = 0.2; /** Longest text embedded as one item, so embedding models with short contexts still cope. */ export const MAX_MEMORY_ITEM_CHARS = 1_000; /** Compacted history summaries stay below this; anything longer is refused. */ const MAX_SAVE_CHARS = 40_000; export type SemanticMemoryDb = Pick; interface BuiltinMemoryConnection { baseUrl: string; model: string; apiKey?: string; } function parseBuiltinMemoryConnection( settings: Record, credentials: Record, ): BuiltinMemoryConnection { const baseUrl = settings.baseUrl?.trim(); if (!baseUrl) throw new Error("baseUrl is required"); const model = settings.model?.trim(); if (!model) throw new Error("model is required"); if (model.length > 256) throw new Error("model must contain at most 256 characters"); const apiKey = credentials.apiKey?.trim(); return { baseUrl: normalizeOpenAiCompatibleBaseUrl(baseUrl), model, ...(apiKey ? { apiKey } : {}), }; } /** Embedding servers on loopback or private addresses are deployment infrastructure. */ export function builtinMemoryRequiresDeploymentOwner(settings: Record): boolean { const baseUrl = settings.baseUrl?.trim(); if (!baseUrl) return false; try { return isPrivateOpenAiCompatibleHostname(new URL(baseUrl).hostname); } catch { return false; } } export function createBuiltinMemoryEmbeddings( settings: Record, credentials: Record, ): EmbeddingProvider { return new OpenAiCompatibleEmbeddingProvider(parseBuiltinMemoryConnection(settings, credentials)); } /** Validates the connection by embedding one probe text before anything is stored. */ export async function prepareBuiltinMemoryConnection( settings: Record, credentials: Record, fetchImpl?: typeof fetch, ): Promise<{ settings: Record; credentials: Record }> { const connection = parseBuiltinMemoryConnection(settings, credentials); await new OpenAiCompatibleEmbeddingProvider(connection, fetchImpl).embed(["connection check"]); return { settings: { baseUrl: connection.baseUrl, model: connection.model }, credentials: connection.apiKey ? { apiKey: connection.apiKey } : {}, }; } export function createBuiltinMemoryProvider( settings: Record, credentials: Record, deps: { prisma: SemanticMemoryDb }, ): SemanticMemoryProvider { return new BuiltinMemoryProvider( deps.prisma, createBuiltinMemoryEmbeddings(settings, credentials), ); } /** Splits long text at paragraph or sentence ends into items of at most `maxChars`. */ export function splitMemoryText(text: string, maxChars = MAX_MEMORY_ITEM_CHARS): string[] { const items: string[] = []; let rest = text.trim(); while (rest.length > maxChars) { const window = rest.slice(0, maxChars); const boundary = Math.max( window.lastIndexOf("\n"), window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "), ); let end = boundary >= maxChars / 2 ? boundary + 1 : maxChars; // Never split a surrogate pair. if (/[\uD800-\uDBFF]/.test(rest.charAt(end - 1))) end -= 1; items.push(rest.slice(0, end).trim()); rest = rest.slice(end).trim(); } if (rest) items.push(rest); return items; } function unavailable(action: string, error: unknown): { ok: false; error: string } { const reason = error instanceof Error ? error.message : String(error); getLogger().warn(`built-in memory could not ${action}`, { reason }); return { ok: false, error: `Built-in memory could not ${action}: ${reason}` }; } /** * Semantic memory stored in this deployment's database. Items keep their embedding as a plain * float array (no vector extension), and recall ranks the newest candidates by cosine * similarity in process. Embeddings come from the connected OpenAI-compatible endpoint. */ export class BuiltinMemoryProvider implements SemanticMemoryProvider { constructor( private readonly prisma: SemanticMemoryDb, private readonly embeddings: EmbeddingProvider, ) {} describe() { return { id: BUILTIN_MEMORY_PROVIDER_ID, contractVersion: "1", adapterVersion: "0.1.0", capabilities: { recall: true, save: true, purgeHistory: true, sharedScope: true, } as const, }; } private get model(): string { return this.embeddings.describe().capabilities.model; } async recall( request: SemanticMemoryRecallRequest, context: AdapterContext, ): Promise> { // Runs pass the whole task prompt; its opening is enough to find related memories. const query = splitMemoryText(request.query)[0]; if (!query || request.limit <= 0) return { ok: true, value: [] }; try { const [vector] = await this.embeddings.embed([query], context.signal); const durable = request.scope === "shared" ? { generation: null, OR: [{ botId: request.botId }, { shared: true }] } : { generation: null, botId: request.botId }; const items = await this.prisma.semanticMemoryItem.findMany({ where: { spaceId: context.spaceId, embeddingModel: this.model, OR: request.historyGeneration === undefined ? [durable] : [durable, { botId: request.botId, generation: request.historyGeneration }], }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: MAX_RECALL_CANDIDATES, select: { id: true, content: true, embedding: true, createdAt: true }, }); const ranked = items .map((item) => ({ item, score: cosineSimilarity(vector!, item.embedding) })) .filter((entry) => entry.score >= MIN_RECALL_SIMILARITY) .sort((left, right) => right.score - left.score); const seen = new Set(); const results: SemanticMemoryResult[] = []; for (const { item, score } of ranked) { if (seen.has(item.content)) continue; seen.add(item.content); results.push({ memory: item.content, score, id: item.id, updatedAt: item.createdAt.toISOString(), }); if (results.length >= request.limit) break; } return { ok: true, value: results }; } catch (error) { return unavailable("recall", error); } } async save( request: SemanticMemorySaveRequest, context: AdapterContext, ): Promise { const content = request.content.trim(); if (!content) return { ok: false, error: "Memory content is empty." }; if (content.length > MAX_SAVE_CHARS) return { ok: false, error: "Memory content is too long." }; const generation = request.source.kind === "history" ? request.source.generation : null; try { const items = splitMemoryText(content); const vectors = await this.embeddings.embed(items, context.signal); await this.prisma.semanticMemoryItem.createMany({ data: items.map((item, index) => ({ spaceId: context.spaceId, botId: request.botId, shared: generation === null && request.scope === "shared", generation, content: item, embedding: vectors[index]!, embeddingModel: this.model, })), }); return { ok: true, value: undefined }; } catch (error) { return unavailable("save", error); } } async purgeHistory( request: SemanticMemoryPurgeHistoryRequest, context: AdapterContext, ): Promise { if (request.generations.length === 0) return { ok: true, value: undefined }; try { await this.prisma.semanticMemoryItem.deleteMany({ where: { spaceId: context.spaceId, botId: request.botId, generation: { in: [...new Set(request.generations)] }, }, }); return { ok: true, value: undefined }; } catch (error) { return unavailable("purge history", error); } } async forget( request: SemanticMemoryForgetRequest, context: AdapterContext, ): Promise { const id = request.id.trim(); // Without a bot the visibility filter below would match every bot's items. if (!id || !context.botId) return { ok: false, error: "A memory id and bot are required." }; try { const removed = await this.prisma.semanticMemoryItem.deleteMany({ where: { id, spaceId: context.spaceId, OR: [{ botId: context.botId }, { shared: true }], }, }); return { ok: true, value: { id, expired: removed.count > 0, reason: request.reason ?? null }, }; } catch (error) { return unavailable("forget", error); } } }