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
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import type { AdapterContext } from "@rakazo/adapter-kit";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
BuiltinMemoryProvider,
|
|
builtinMemoryRequiresDeploymentOwner,
|
|
MAX_MEMORY_ITEM_CHARS,
|
|
prepareBuiltinMemoryConnection,
|
|
splitMemoryText,
|
|
} from "./builtin-memory-provider.js";
|
|
import {
|
|
EmulatorEmbeddingProvider,
|
|
emulatedEmbedding,
|
|
OpenAiCompatibleEmbeddingServerEmulator,
|
|
} from "./embedding-emulator.js";
|
|
|
|
const context: AdapterContext = {
|
|
operationId: "op-1",
|
|
traceId: "trace-1",
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
};
|
|
|
|
function item(id: string, content: string, model = "emulator") {
|
|
return {
|
|
id,
|
|
content,
|
|
embedding: emulatedEmbedding(content),
|
|
embeddingModel: model,
|
|
createdAt: new Date("2026-10-01T10:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
function fakeDb(items: Array<ReturnType<typeof item>> = []) {
|
|
return {
|
|
semanticMemoryItem: {
|
|
findMany: vi.fn(async () => items),
|
|
createMany: vi.fn(async (args: { data: unknown[] }) => ({ count: args.data.length })),
|
|
deleteMany: vi.fn(async () => ({ count: 1 })),
|
|
},
|
|
};
|
|
}
|
|
|
|
function providerFor(db: ReturnType<typeof fakeDb>) {
|
|
return new BuiltinMemoryProvider(db as never, new EmulatorEmbeddingProvider());
|
|
}
|
|
|
|
describe("builtinMemoryRequiresDeploymentOwner", () => {
|
|
it.each([
|
|
["http://127.0.0.1:8080/v1", true],
|
|
["http://localhost:8080", true],
|
|
["http://192.168.1.20:8080/v1", true],
|
|
["http://100.64.1.2:8080/v1", true],
|
|
["https://embeddings.example.com/v1", false],
|
|
["not a url", false],
|
|
])("classifies %s", (baseUrl, expected) => {
|
|
expect(builtinMemoryRequiresDeploymentOwner({ baseUrl, model: "m" })).toBe(expected);
|
|
});
|
|
|
|
it("does not require the owner before an endpoint is chosen", () => {
|
|
expect(builtinMemoryRequiresDeploymentOwner({})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("prepareBuiltinMemoryConnection", () => {
|
|
it("probes the endpoint and stores the normalized connection with the key apart", async () => {
|
|
const server = new OpenAiCompatibleEmbeddingServerEmulator({ apiKey: "sk-local-key" });
|
|
|
|
const prepared = await prepareBuiltinMemoryConnection(
|
|
{ baseUrl: " http://127.0.0.1:8080/ ", model: " nomic-embed-text " },
|
|
{ apiKey: "sk-local-key" },
|
|
server.fetch,
|
|
);
|
|
|
|
expect(prepared).toEqual({
|
|
settings: { baseUrl: "http://127.0.0.1:8080/v1", model: "nomic-embed-text" },
|
|
credentials: { apiKey: "sk-local-key" },
|
|
});
|
|
expect(server.requests).toHaveLength(1);
|
|
});
|
|
|
|
it("connects keyless servers with empty credentials", async () => {
|
|
const prepared = await prepareBuiltinMemoryConnection(
|
|
{ baseUrl: "http://127.0.0.1:8080/v1", model: "bge-m3" },
|
|
{},
|
|
new OpenAiCompatibleEmbeddingServerEmulator().fetch,
|
|
);
|
|
expect(prepared.credentials).toEqual({});
|
|
});
|
|
|
|
it("fails before storing anything when the endpoint does not embed", async () => {
|
|
const server = new OpenAiCompatibleEmbeddingServerEmulator();
|
|
server.failNextStatus = 404;
|
|
await expect(
|
|
prepareBuiltinMemoryConnection(
|
|
{ baseUrl: "http://127.0.0.1:8080/v1", model: "chat-only" },
|
|
{},
|
|
server.fetch,
|
|
),
|
|
).rejects.toThrow("Embeddings server returned 404");
|
|
await expect(prepareBuiltinMemoryConnection({ model: "m" }, {})).rejects.toThrow(
|
|
/baseUrl is required/,
|
|
);
|
|
await expect(
|
|
prepareBuiltinMemoryConnection({ baseUrl: "http://127.0.0.1:8080" }, {}),
|
|
).rejects.toThrow(/model is required/);
|
|
});
|
|
});
|
|
|
|
describe("splitMemoryText", () => {
|
|
it("keeps short memories whole", () => {
|
|
expect(splitMemoryText(" Prefers tea. ")).toEqual(["Prefers tea."]);
|
|
});
|
|
|
|
it("splits long text at sentence ends into bounded items", () => {
|
|
const sentence = "The project review moved to Thursday afternoon. ";
|
|
const items = splitMemoryText(sentence.repeat(60));
|
|
expect(items.length).toBeGreaterThan(1);
|
|
for (const part of items) {
|
|
expect(part.length).toBeLessThanOrEqual(MAX_MEMORY_ITEM_CHARS);
|
|
expect(part.endsWith(".")).toBe(true);
|
|
}
|
|
expect(items.join(" ")).toBe(sentence.repeat(60).trim());
|
|
});
|
|
|
|
it("never splits a surrogate pair", () => {
|
|
const items = splitMemoryText(`${"a".repeat(9)}😀${"b".repeat(5)}`, 10);
|
|
expect(items[0]).toBe("a".repeat(9));
|
|
expect(items[1]?.startsWith("😀")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("BuiltinMemoryProvider", () => {
|
|
it("recalls only the bot's durable items in isolated scope", async () => {
|
|
const db = fakeDb();
|
|
await providerFor(db).recall(
|
|
{ query: "jazz", scope: "isolated", botId: "bot-1", limit: 5 },
|
|
context,
|
|
);
|
|
expect(db.semanticMemoryItem.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
spaceId: "space-1",
|
|
embeddingModel: "emulator",
|
|
OR: [{ generation: null, botId: "bot-1" }],
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("adds the Space's shared items and the current history generation when asked", async () => {
|
|
const db = fakeDb();
|
|
await providerFor(db).recall(
|
|
{ query: "jazz", scope: "shared", botId: "bot-1", historyGeneration: 3, limit: 5 },
|
|
context,
|
|
);
|
|
expect(db.semanticMemoryItem.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
spaceId: "space-1",
|
|
embeddingModel: "emulator",
|
|
OR: [
|
|
{ generation: null, OR: [{ botId: "bot-1" }, { shared: true }] },
|
|
{ botId: "bot-1", generation: 3 },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("ranks by similarity, drops unrelated and repeated items, and honors the limit", async () => {
|
|
const db = fakeDb([
|
|
item("deadline", "Project deadline is Friday"),
|
|
item("jazz-1", "User likes jazz records"),
|
|
item("jazz-2", "User likes jazz records"),
|
|
item("jazz-3", "Listens to jazz"),
|
|
]);
|
|
const result = await providerFor(db).recall(
|
|
{ query: "jazz records", scope: "isolated", botId: "bot-1", limit: 5 },
|
|
context,
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
const value = result.ok ? result.value : [];
|
|
expect(value.map((entry) => entry.id)).toEqual(["jazz-1", "jazz-3"]);
|
|
expect(value[0]).toMatchObject({
|
|
memory: "User likes jazz records",
|
|
updatedAt: "2026-10-01T10:00:00.000Z",
|
|
});
|
|
expect(value[0]!.score).toBeGreaterThan(value[1]!.score);
|
|
|
|
const limited = await providerFor(db).recall(
|
|
{ query: "jazz records", scope: "isolated", botId: "bot-1", limit: 1 },
|
|
context,
|
|
);
|
|
expect(limited.ok && limited.value.map((entry) => entry.id)).toEqual(["jazz-1"]);
|
|
});
|
|
|
|
it("embeds only the opening of a long prompt used as the query", async () => {
|
|
const embeddings = new EmulatorEmbeddingProvider();
|
|
const embed = vi.spyOn(embeddings, "embed");
|
|
const provider = new BuiltinMemoryProvider(fakeDb() as never, embeddings);
|
|
await provider.recall(
|
|
{
|
|
query: "Please review this document. ".repeat(200),
|
|
scope: "isolated",
|
|
botId: "bot-1",
|
|
limit: 5,
|
|
},
|
|
context,
|
|
);
|
|
const [texts] = embed.mock.calls[0]!;
|
|
expect(texts).toHaveLength(1);
|
|
expect(texts[0]!.length).toBeLessThanOrEqual(MAX_MEMORY_ITEM_CHARS);
|
|
});
|
|
|
|
it("answers an empty query without embedding or reading", async () => {
|
|
const db = fakeDb();
|
|
await expect(
|
|
providerFor(db).recall({ query: " ", scope: "isolated", botId: "bot-1", limit: 5 }, context),
|
|
).resolves.toEqual({ ok: true, value: [] });
|
|
expect(db.semanticMemoryItem.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports embedding failures instead of throwing into the run", async () => {
|
|
const provider = new BuiltinMemoryProvider(fakeDb() as never, {
|
|
describe: () => new EmulatorEmbeddingProvider().describe(),
|
|
embed: async () => {
|
|
throw new Error("Embeddings server returned 503");
|
|
},
|
|
});
|
|
await expect(
|
|
provider.recall({ query: "jazz", scope: "isolated", botId: "bot-1", limit: 5 }, context),
|
|
).resolves.toEqual({
|
|
ok: false,
|
|
error: "Built-in memory could not recall: Embeddings server returned 503",
|
|
});
|
|
await expect(
|
|
provider.save(
|
|
{ content: "x", scope: "isolated", botId: "bot-1", source: { kind: "durable" } },
|
|
context,
|
|
),
|
|
).resolves.toMatchObject({ ok: false });
|
|
});
|
|
|
|
it("saves shared durable memories with their embedding", async () => {
|
|
const db = fakeDb();
|
|
await expect(
|
|
providerFor(db).save(
|
|
{
|
|
content: "Prefers tea over coffee.",
|
|
scope: "shared",
|
|
botId: "bot-1",
|
|
source: { kind: "durable" },
|
|
},
|
|
context,
|
|
),
|
|
).resolves.toEqual({ ok: true, value: undefined });
|
|
expect(db.semanticMemoryItem.createMany).toHaveBeenCalledWith({
|
|
data: [
|
|
{
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
shared: true,
|
|
generation: null,
|
|
content: "Prefers tea over coffee.",
|
|
embedding: emulatedEmbedding("Prefers tea over coffee."),
|
|
embeddingModel: "emulator",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("splits a long history summary into private items of its generation", async () => {
|
|
const db = fakeDb();
|
|
const summary = "The user planned the launch with the team. ".repeat(60);
|
|
await providerFor(db).save(
|
|
{
|
|
content: summary,
|
|
scope: "shared",
|
|
botId: "bot-1",
|
|
source: { kind: "history", generation: 2 },
|
|
},
|
|
context,
|
|
);
|
|
const data = db.semanticMemoryItem.createMany.mock.calls[0]![0].data as Array<{
|
|
shared: boolean;
|
|
generation: number | null;
|
|
content: string;
|
|
}>;
|
|
expect(data.length).toBeGreaterThan(1);
|
|
expect(data.every((row) => !row.shared && row.generation === 2)).toBe(true);
|
|
expect(data.map((row) => row.content).join(" ")).toBe(summary.trim());
|
|
});
|
|
|
|
it("refuses empty and oversized memories", async () => {
|
|
const provider = providerFor(fakeDb());
|
|
const request = {
|
|
scope: "isolated" as const,
|
|
botId: "bot-1",
|
|
source: { kind: "durable" as const },
|
|
};
|
|
await expect(provider.save({ ...request, content: " " }, context)).resolves.toMatchObject({
|
|
ok: false,
|
|
});
|
|
await expect(
|
|
provider.save({ ...request, content: "x".repeat(40_001) }, context),
|
|
).resolves.toMatchObject({ ok: false, error: "Memory content is too long." });
|
|
});
|
|
|
|
it("purges history generations of one bot in the Space", async () => {
|
|
const db = fakeDb();
|
|
const provider = providerFor(db);
|
|
await provider.purgeHistory({ botId: "bot-1", generations: [] }, context);
|
|
expect(db.semanticMemoryItem.deleteMany).not.toHaveBeenCalled();
|
|
await provider.purgeHistory({ botId: "bot-1", generations: [1, 1, 2] }, context);
|
|
expect(db.semanticMemoryItem.deleteMany).toHaveBeenCalledWith({
|
|
where: { spaceId: "space-1", botId: "bot-1", generation: { in: [1, 2] } },
|
|
});
|
|
});
|
|
|
|
it("forgets only items the bot can recall, and never without a bot", async () => {
|
|
const db = fakeDb();
|
|
const provider = providerFor(db);
|
|
await expect(provider.forget({ id: "item-1", reason: "outdated" }, context)).resolves.toEqual({
|
|
ok: true,
|
|
value: { id: "item-1", expired: true, reason: "outdated" },
|
|
});
|
|
expect(db.semanticMemoryItem.deleteMany).toHaveBeenCalledWith({
|
|
where: { id: "item-1", spaceId: "space-1", OR: [{ botId: "bot-1" }, { shared: true }] },
|
|
});
|
|
db.semanticMemoryItem.deleteMany.mockClear();
|
|
await expect(
|
|
provider.forget({ id: "item-1" }, { ...context, botId: undefined }),
|
|
).resolves.toMatchObject({ ok: false });
|
|
expect(db.semanticMemoryItem.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|