Why: the user profile only knew what onboarding wrote, so people had to repeat themselves,
and semantic recall needed a hosted or separately run memory service. The profile now grows
from everyday conversations, and recall can run on the user's own server with a local
embeddings endpoint (for example llama.cpp). Both stay optional and are tested offline.
What:
- adapter-kit: EmbeddingProvider contract (describe, embed) and the profile.consolidate job
(payload schema and a replace-keyed job factory, one pending job per user).
- adapters: OpenAI-compatible embeddings (POST {baseUrl}/embeddings through the guarded
OpenAI-compatible fetch, batching, bounded responses, clear errors), a deterministic
emulator, an in-process /v1/embeddings server emulator, and a shared conformance suite.
- adapters: "builtin" semantic memory stored in Postgres (semantic_memory_items with Float[]
embeddings, cosine ranking in process, bot/shared/history scopes matching the existing
providers), registered in the memory provider factory and configured through
memory.connectProvider with an embeddings URL, model and optional API key. Private
endpoints require the deployment owner, like the other local memory providers.
- adapters: profile consolidation reads the user's settled app conversations since
UserProfile.consolidatedAt (never messaging channels), asks the main bot's model for strict
JSON facts and an updated summary, drops restatements (embedding cosine when built-in
memory is configured, normalized text otherwise), stores sensitive categories as pending,
and advances the cursor with a compare-and-set. A reconciler hook enqueues it at most daily
per active user; it skips under the scripted runtime and without a usable model.
- db: additive migration 20261002210400_ws4_semantic_memory.
- web: a "Built-in" option in Memory settings; AI data sharing names the embeddings server.
Tests: new embedding conformance, OpenAI-compatible wire, built-in provider and consolidation
unit tests; new Postgres suites for built-in memory (including a real loopback embeddings
server through the memory provider resolver) and for consolidation and its scheduling query,
both added to the integration harness. The settings E2E spec now opens the new form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
260 lines
8.9 KiB
TypeScript
260 lines
8.9 KiB
TypeScript
import type {
|
|
DurableMemoryScope,
|
|
EmbeddingProvider,
|
|
SemanticMemoryProvider,
|
|
} from "@rakazo/adapter-kit";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import type { SemanticMemoryDb } from "./builtin-memory-provider.js";
|
|
import {
|
|
BUILTIN_MEMORY_PROVIDER_ID,
|
|
builtinMemoryRequiresDeploymentOwner,
|
|
createBuiltinMemoryEmbeddings,
|
|
createBuiltinMemoryProvider,
|
|
prepareBuiltinMemoryConnection,
|
|
} from "./builtin-memory-provider.js";
|
|
import type { EncryptedSecretStore } from "./secrets.js";
|
|
import {
|
|
classifySerenityConnectionSettings,
|
|
createSerenityProvider,
|
|
prepareSerenityConnection,
|
|
SERENITY_PROVIDER_ID,
|
|
serenityRequiresDeploymentOwner,
|
|
} from "./serenity-memory-provider.js";
|
|
|
|
export { MemoryProviderDeploymentOwnerRequiredError } from "./serenity-memory-provider.js";
|
|
|
|
import {
|
|
createSupermemoryProvider,
|
|
decodeLegacySupermemoryCredentials,
|
|
prepareSupermemoryConnection,
|
|
SUPERMEMORY_PROVIDER_ID,
|
|
supermemoryRequiresDeploymentOwner,
|
|
} from "./supermemory-memory-provider.js";
|
|
|
|
export interface MemoryProviderConnectionInput {
|
|
provider: string;
|
|
settings: Record<string, string>;
|
|
credentials: Record<string, string>;
|
|
/** When false, Serenity must not probe endpoints that classify as private. */
|
|
allowPrivateEndpoint?: boolean;
|
|
}
|
|
|
|
export interface PreparedMemoryProviderConnection {
|
|
provider: string;
|
|
settings: Record<string, string>;
|
|
credentials: Record<string, string>;
|
|
}
|
|
|
|
export interface ConfiguredMemoryProvider {
|
|
provider: SemanticMemoryProvider;
|
|
defaultScope: DurableMemoryScope;
|
|
/** Embeddings served by the same connection, when the provider has one. */
|
|
embeddings?: EmbeddingProvider;
|
|
}
|
|
|
|
/** What a provider may need beyond its own connection, e.g. the database for built-in memory. */
|
|
export interface MemoryProviderDeps {
|
|
prisma: SemanticMemoryDb;
|
|
}
|
|
|
|
export interface MemoryProviderResolveRequest {
|
|
spaceId: string;
|
|
userId: string;
|
|
}
|
|
|
|
export function memoryProviderResolveRequest(
|
|
spaceId: string,
|
|
userId: string,
|
|
): MemoryProviderResolveRequest {
|
|
return { spaceId, userId };
|
|
}
|
|
|
|
export interface MemoryProviderResolver {
|
|
resolve(request: MemoryProviderResolveRequest): Promise<ConfiguredMemoryProvider | null>;
|
|
}
|
|
|
|
async function isSpaceOwner(
|
|
prisma: Pick<PrismaClient, "spaceMember">,
|
|
spaceId: string,
|
|
userId: string,
|
|
): Promise<boolean> {
|
|
const member = await prisma.spaceMember.findUnique({
|
|
where: { spaceId_userId: { spaceId, userId } },
|
|
select: { role: true },
|
|
});
|
|
const roles = member?.role.split(",").map((role) => role.trim());
|
|
return roles?.includes("owner") ?? false;
|
|
}
|
|
|
|
interface MemoryProviderAdapter {
|
|
requiresDeploymentOwner(settings: Record<string, string>): boolean;
|
|
/** Async trust classification (e.g. DNS) without probing credentials. */
|
|
classifySettings?(settings: Record<string, string>): Promise<Record<string, string>>;
|
|
prepare(
|
|
settings: Record<string, string>,
|
|
credentials: Record<string, string>,
|
|
options?: { allowPrivateEndpoint?: boolean },
|
|
): Promise<{ settings: Record<string, string>; credentials: Record<string, string> }>;
|
|
create(
|
|
settings: Record<string, string>,
|
|
credentials: Record<string, string>,
|
|
deps: MemoryProviderDeps,
|
|
): SemanticMemoryProvider;
|
|
embeddings?(
|
|
settings: Record<string, string>,
|
|
credentials: Record<string, string>,
|
|
): EmbeddingProvider;
|
|
decodeLegacyCredentials?(plaintext: string): Record<string, string> | null;
|
|
}
|
|
|
|
const MEMORY_PROVIDER_ADAPTERS: ReadonlyMap<string, MemoryProviderAdapter> = new Map([
|
|
[
|
|
SUPERMEMORY_PROVIDER_ID,
|
|
{
|
|
requiresDeploymentOwner: supermemoryRequiresDeploymentOwner,
|
|
prepare: prepareSupermemoryConnection,
|
|
create: createSupermemoryProvider,
|
|
decodeLegacyCredentials: decodeLegacySupermemoryCredentials,
|
|
},
|
|
],
|
|
[
|
|
SERENITY_PROVIDER_ID,
|
|
{
|
|
requiresDeploymentOwner: serenityRequiresDeploymentOwner,
|
|
classifySettings: classifySerenityConnectionSettings,
|
|
prepare: (
|
|
settings: Record<string, string>,
|
|
credentials: Record<string, string>,
|
|
options?: { allowPrivateEndpoint?: boolean },
|
|
) => prepareSerenityConnection(settings, credentials, undefined, options),
|
|
create: createSerenityProvider,
|
|
},
|
|
],
|
|
[
|
|
BUILTIN_MEMORY_PROVIDER_ID,
|
|
{
|
|
requiresDeploymentOwner: builtinMemoryRequiresDeploymentOwner,
|
|
prepare: (settings: Record<string, string>, credentials: Record<string, string>) =>
|
|
prepareBuiltinMemoryConnection(settings, credentials),
|
|
create: createBuiltinMemoryProvider,
|
|
embeddings: createBuiltinMemoryEmbeddings,
|
|
},
|
|
],
|
|
]);
|
|
|
|
function memoryProviderAdapter(provider: string): MemoryProviderAdapter {
|
|
const adapter = MEMORY_PROVIDER_ADAPTERS.get(provider);
|
|
if (!adapter) throw new Error(`Unknown memory provider "${provider}".`);
|
|
return adapter;
|
|
}
|
|
|
|
/** Adapters classify their settings; callers enforce the deployment trust boundary. */
|
|
export function memoryProviderRequiresDeploymentOwner(
|
|
provider: string,
|
|
settings: Record<string, string>,
|
|
): boolean {
|
|
return memoryProviderAdapter(provider).requiresDeploymentOwner(settings);
|
|
}
|
|
|
|
/**
|
|
* Run provider-specific trust classification (DNS, etc.) without credentialed probes.
|
|
* Callers must authorize deployment-owner endpoints before prepare/probe.
|
|
*/
|
|
export async function classifyMemoryProviderSettings(
|
|
provider: string,
|
|
settings: Record<string, string>,
|
|
): Promise<Record<string, string>> {
|
|
const adapter = memoryProviderAdapter(provider);
|
|
return adapter.classifySettings ? adapter.classifySettings(settings) : settings;
|
|
}
|
|
|
|
export async function prepareMemoryProviderConnection(
|
|
input: MemoryProviderConnectionInput,
|
|
): Promise<PreparedMemoryProviderConnection> {
|
|
const prepared = await memoryProviderAdapter(input.provider).prepare(
|
|
input.settings,
|
|
input.credentials,
|
|
{ allowPrivateEndpoint: input.allowPrivateEndpoint },
|
|
);
|
|
return { provider: input.provider, ...prepared };
|
|
}
|
|
|
|
export function createMemoryProvider(
|
|
provider: string,
|
|
settings: Record<string, string>,
|
|
credentials: Record<string, string>,
|
|
deps: MemoryProviderDeps,
|
|
): SemanticMemoryProvider {
|
|
return memoryProviderAdapter(provider).create(settings, credentials, deps);
|
|
}
|
|
|
|
export function toStringRecord(value: unknown): Record<string, string> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(value).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
);
|
|
}
|
|
|
|
function decodeCredentials(provider: string, plaintext: string): Record<string, string> {
|
|
try {
|
|
const parsed: unknown = JSON.parse(plaintext);
|
|
// A keyless connection (e.g. a local embeddings server) stores an empty object.
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return toStringRecord(parsed);
|
|
}
|
|
} catch {
|
|
// Configurations created before the generic provider boundary stored the API key directly.
|
|
}
|
|
const legacyCredentials = memoryProviderAdapter(provider).decodeLegacyCredentials?.(plaintext);
|
|
if (legacyCredentials) return legacyCredentials;
|
|
throw new Error(`Stored credentials for memory provider "${provider}" are invalid.`);
|
|
}
|
|
|
|
export class SpaceMemoryProviderResolver implements MemoryProviderResolver {
|
|
constructor(
|
|
private readonly prisma: Pick<
|
|
PrismaClient,
|
|
"spaceMemoryConfig" | "deploymentSettings" | "spaceMember" | "semanticMemoryItem"
|
|
>,
|
|
private readonly secrets: EncryptedSecretStore,
|
|
) {}
|
|
|
|
async resolve({
|
|
spaceId,
|
|
userId,
|
|
}: MemoryProviderResolveRequest): Promise<ConfiguredMemoryProvider | null> {
|
|
const config = await this.prisma.spaceMemoryConfig.findUnique({
|
|
where: { spaceId },
|
|
include: { secret: true },
|
|
});
|
|
if (!config) return null;
|
|
const settings = toStringRecord(config.settings);
|
|
if (memoryProviderRequiresDeploymentOwner(config.provider, settings)) {
|
|
const deployment = await this.prisma.deploymentSettings.findUnique({
|
|
where: { id: "default" },
|
|
select: { ownerUserId: true },
|
|
});
|
|
// Also disable pre-existing local configurations authored outside the deployment boundary.
|
|
if (!deployment?.ownerUserId || deployment.ownerUserId !== config.userId) return null;
|
|
} else if (!(await isSpaceOwner(this.prisma, spaceId, userId))) {
|
|
// Cloud/public providers are owner-managed infrastructure; members must not
|
|
// load credentials or hit external endpoints through the runtime path.
|
|
return null;
|
|
}
|
|
const credentials = decodeCredentials(
|
|
config.provider,
|
|
this.secrets.load(config.secret.ciphertext, config.secret.id),
|
|
);
|
|
const embeddings = memoryProviderAdapter(config.provider).embeddings?.(settings, credentials);
|
|
return {
|
|
provider: createMemoryProvider(config.provider, settings, credentials, {
|
|
prisma: this.prisma,
|
|
}),
|
|
defaultScope: config.defaultMemoryScope === "shared" ? "shared" : "isolated",
|
|
...(embeddings ? { embeddings } : {}),
|
|
};
|
|
}
|
|
}
|