import { createHash } from "node:crypto"; import type { AdapterContext, DurableMemoryScope, SemanticMemoryForgetRequest, SemanticMemoryProvider, SemanticMemoryRecallRequest, SemanticMemoryResponse, SemanticMemoryResult, SemanticMemorySaveRequest, } from "@rakazo/adapter-kit"; import { classifySerenityEndpointTrust, forgetSerenity, normalizeSerenityEndpoint, parseSerenityEndpoint, probeSerenity, recallSerenity, rememberSerenity, type SerenityConnectionConfig, type SerenityNetworkDependencies, serenityEndpointRequiresDeploymentOwner, } from "./serenity-client.js"; export const SERENITY_PROVIDER_ID = "serenity"; /** Thrown when prepare would probe a private endpoint without deployment-owner authorization. */ export class MemoryProviderDeploymentOwnerRequiredError extends Error { readonly code = "DEPLOYMENT_OWNER_REQUIRED" as const; constructor(message = "This memory provider endpoint requires deployment-owner authorization.") { super(message); this.name = "MemoryProviderDeploymentOwnerRequiredError"; } } function requiredValue(values: Record, key: string): string { const value = values[key]?.trim(); if (!value) throw new Error(`${key} is required`); return value; } function parseBooleanSetting(value: string | undefined, defaultValue: boolean): boolean { if (value === undefined || value === "") return defaultValue; if (value === "true" || value === "1") return true; if (value === "false" || value === "0") return false; throw new Error(`expected boolean setting, got "${value}"`); } function parseSerenityConnection( settings: Record, credentials: Record, ): SerenityConnectionConfig & { brainLabel: string; allowWrites: boolean; } { const endpoint = normalizeSerenityEndpoint(requiredValue(settings, "endpoint")); parseSerenityEndpoint(endpoint); const token = requiredValue(credentials, "token"); if (token.length < 8) throw new Error("token must contain at least 8 characters"); const endpointTrust = settings.endpointTrust === "private" ? "private" : "public"; return { endpoint, token, endpointTrust, brainLabel: settings.brainLabel?.trim() ?? "", allowWrites: parseBooleanSetting(settings.allowWrites, false), }; } export function serenityRequiresDeploymentOwner(settings: Record): boolean { if (settings.endpointTrust === "private") return true; const endpoint = settings.endpoint?.trim(); if (!endpoint) return false; try { return serenityEndpointRequiresDeploymentOwner(normalizeSerenityEndpoint(endpoint)); } catch { return false; } } /** * DNS/trust classification without probing. Callers must enforce deployment-owner * authorization on the result before any credentialed Serenity request. */ export async function classifySerenityConnectionSettings( settings: Record, network?: SerenityNetworkDependencies, ): Promise> { const endpoint = normalizeSerenityEndpoint(requiredValue(settings, "endpoint")); parseSerenityEndpoint(endpoint); const endpointTrust = await classifySerenityEndpointTrust(endpoint, network?.resolveHostname); const classified: Record = { ...settings, endpoint }; if (endpointTrust === "private") classified.endpointTrust = "private"; else delete classified.endpointTrust; return classified; } export async function prepareSerenityConnection( settings: Record, credentials: Record, network?: SerenityNetworkDependencies, options?: { allowPrivateEndpoint?: boolean }, ): Promise<{ settings: Record; credentials: Record }> { const classifiedSettings = await classifySerenityConnectionSettings(settings, network); // Reclassification can flip public→private between the API owner check and this probe. // Non-owners must fail closed here before any credentialed MCP request. if (classifiedSettings.endpointTrust === "private" && options?.allowPrivateEndpoint === false) { throw new MemoryProviderDeploymentOwnerRequiredError(); } const connection = parseSerenityConnection(classifiedSettings, credentials); const probe = await probeSerenity( { endpoint: connection.endpoint, token: connection.token, endpointTrust: connection.endpointTrust, }, undefined, network, { requireWrites: connection.allowWrites }, ); if (!probe.ok) throw new Error(probe.error); return { settings: { endpoint: connection.endpoint, allowWrites: connection.allowWrites ? "true" : "false", ...(connection.endpointTrust === "private" ? { endpointTrust: "private" } : {}), ...(connection.brainLabel ? { brainLabel: connection.brainLabel } : {}), }, credentials: { token: connection.token }, }; } export function createSerenityProvider( settings: Record, credentials: Record, ): SemanticMemoryProvider { const connection = parseSerenityConnection(settings, credentials); return new SerenityMemoryProvider(connection); } /** * Bot-scoped entity slug kept inside the adapter (Serenity has no container tags). * Appends a short digest of the case-folded original so labels that sanitize * identically (e.g. "prod brain" vs "prod-brain") stay in separate namespaces. */ export function sanitizeSerenityBrainLabel(label: string): string { const trimmed = label.trim(); if (!trimmed) return ""; const lower = trimmed.toLowerCase(); const slug = lower.replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); if (!slug) return ""; const digest = createHash("sha256").update(lower).digest("hex").slice(0, 8); return `${slug.slice(0, 55)}-${digest}`; } export function serenityBotEntity(botId: string, brainLabel = ""): string { const label = sanitizeSerenityBrainLabel(brainLabel); return label ? `rakazo-bot/${label}/${botId}` : `rakazo-bot/${botId}`; } export function serenitySpaceEntity(spaceId: string, brainLabel = ""): string { const label = sanitizeSerenityBrainLabel(brainLabel); return label ? `rakazo-space/${label}/${spaceId}` : `rakazo-space/${spaceId}`; } function durableEntities( scope: DurableMemoryScope, botId: string, spaceId: string, brainLabel: string, ): string[] { const bot = serenityBotEntity(botId, brainLabel); return scope === "shared" ? [serenitySpaceEntity(spaceId, brainLabel), bot] : [bot]; } export class SerenityMemoryProvider implements SemanticMemoryProvider { constructor( private readonly connection: SerenityConnectionConfig & { brainLabel: string; allowWrites: boolean; }, ) {} describe() { return { id: SERENITY_PROVIDER_ID, contractVersion: "1", adapterVersion: "0.1.0", capabilities: { recall: true, save: true, purgeHistory: true, sharedScope: true, } as const, }; } async recall( request: SemanticMemoryRecallRequest, context: AdapterContext, ): Promise> { // History compaction stays in Rakazo; Serenity is the durable brain only. const entities = durableEntities( request.scope, request.botId, context.spaceId, this.connection.brainLabel, ); const results = await Promise.all( entities.map(async (entity) => ({ entity, result: await recallSerenity(request.query, this.connection, { limit: request.limit, entity, signal: context.signal, }), })), ); const errors = results.filter((entry) => !entry.result.ok); if (errors.length === results.length) { return { ok: false, error: errors.map((entry) => (entry.result.ok ? "" : entry.result.error)).join("; "), }; } const seen = new Set(); const merged: SemanticMemoryResult[] = []; for (const { entity, result } of results) { if (!result.ok) continue; for (const fact of result.value) { if (seen.has(fact.factId)) continue; seen.add(fact.factId); merged.push({ memory: fact.fact, score: 1, id: fact.factId, provenance: fact.provenance, entity, }); } } return { ok: true, value: merged.slice(0, request.limit) }; } async save( request: SemanticMemorySaveRequest, context: AdapterContext, ): Promise { if (request.source.kind === "history") { // Conversation summaries stay out of the user-owned Serenity brain. return { ok: true, value: undefined }; } if (!this.connection.allowWrites) { return { ok: false, error: "Serenity writes are disabled for this Space. Enable writing in Memory settings to save durable facts.", }; } const entities = durableEntities( request.scope, request.botId, context.spaceId, this.connection.brainLabel, ); const provenance = `rakazo space:${context.spaceId} bot:${request.botId}`; const results = await Promise.all( entities.map((entity) => rememberSerenity(request.content, provenance, this.connection, { entity, signal: context.signal, }), ), ); const errors = results.filter((result) => !result.ok).map((result) => result.error); return errors.length > 0 ? { ok: false, error: errors.join("; ") } : { ok: true, value: undefined }; } async purgeHistory( _request: { botId: string; generations: number[] }, _context: AdapterContext, ): Promise { // History generations are never written to Serenity. return { ok: true, value: undefined }; } async forget( request: SemanticMemoryForgetRequest, context: AdapterContext, ): Promise> { if (!this.connection.allowWrites) { return { ok: false, error: "Serenity writes are disabled for this Space. Enable writing in Memory settings to forget facts.", }; } // Serenity forget is id-scoped (opaque fact_id); entity namespaces do not apply. const result = await forgetSerenity(request.id, this.connection, { reason: request.reason, signal: context.signal, }); return result.ok ? { ok: true, value: result.value } : result; } }