From 7a35bca2ec4fb19b6c7f55f735cbf36308d6b664 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 02:01:07 +0000 Subject: [PATCH] refactor: make memory embedding adapters generic --- .../memory-core/src/memory/embeddings.ts | 14 ++- .../src/memory/manager-sync-ops.ts | 19 +--- .../src/memory/provider-adapters.ts | 10 ++ package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + src/plugin-sdk/memory-core-engine-runtime.ts | 7 ++ src/plugin-sdk/memory-core.ts | 5 +- ...memory-embedding-provider.contract.test.ts | 97 +++++++++++++++++++ src/plugins/loader.ts | 16 +-- .../memory-embedding-providers.test.ts | 35 +++++++ src/plugins/memory-embedding-providers.ts | 44 +++++++-- src/plugins/registry.ts | 22 ++++- 12 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 src/plugin-sdk/memory-core-engine-runtime.ts create mode 100644 src/plugins/contracts/memory-embedding-provider.contract.test.ts diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index b88de764228..a1b2924fed7 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -12,7 +12,10 @@ import { type MemoryEmbeddingProviderCreateOptions, type MemoryEmbeddingProviderRuntime, } from "../engine-host-api.js"; -import { canAutoSelectLocal } from "./provider-adapters.js"; +import { + canAutoSelectLocal, + getBuiltinMemoryEmbeddingProviderAdapter, +} from "./provider-adapters.js"; export { DEFAULT_GEMINI_EMBEDDING_MODEL, @@ -92,6 +95,15 @@ function resolveProviderModel( return adapter.defaultModel ?? ""; } +export function resolveEmbeddingProviderFallbackModel( + providerId: string, + fallbackSourceModel: string, +): string { + const adapter = + getMemoryEmbeddingProvider(providerId) ?? getBuiltinMemoryEmbeddingProviderAdapter(providerId); + return adapter?.defaultModel ?? fallbackSourceModel; +} + async function createWithAdapter( adapter: MemoryEmbeddingProviderAdapter, options: CreateEmbeddingProviderOptions, diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 79a679025de..2fd42639788 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -32,14 +32,10 @@ import { } from "../engine-host-api.js"; import { createEmbeddingProvider, - DEFAULT_GEMINI_EMBEDDING_MODEL, - DEFAULT_MISTRAL_EMBEDDING_MODEL, - DEFAULT_OLLAMA_EMBEDDING_MODEL, - DEFAULT_OPENAI_EMBEDDING_MODEL, - DEFAULT_VOYAGE_EMBEDDING_MODEL, type EmbeddingProvider, type EmbeddingProviderId, type EmbeddingProviderRuntime, + resolveEmbeddingProviderFallbackModel, } from "./embeddings.js"; import { buildFileEntry } from "./internal.js"; import { loadSqliteVecExtension } from "./sqlite-vec.js"; @@ -1119,18 +1115,7 @@ export abstract class MemoryManagerSyncOps { } const fallbackFrom = this.provider.id as EmbeddingProviderId; - const fallbackModel = - fallback === "gemini" - ? DEFAULT_GEMINI_EMBEDDING_MODEL - : fallback === "openai" - ? DEFAULT_OPENAI_EMBEDDING_MODEL - : fallback === "voyage" - ? DEFAULT_VOYAGE_EMBEDDING_MODEL - : fallback === "mistral" - ? DEFAULT_MISTRAL_EMBEDDING_MODEL - : fallback === "ollama" - ? DEFAULT_OLLAMA_EMBEDDING_MODEL - : this.settings.model; + const fallbackModel = resolveEmbeddingProviderFallbackModel(fallback, this.settings.model); const fallbackResult = await createEmbeddingProvider({ config: this.cfg, diff --git a/extensions/memory-core/src/memory/provider-adapters.ts b/extensions/memory-core/src/memory/provider-adapters.ts index 9be26207149..e3d3ff1bd40 100644 --- a/extensions/memory-core/src/memory/provider-adapters.ts +++ b/extensions/memory-core/src/memory/provider-adapters.ts @@ -334,6 +334,16 @@ export const builtinMemoryEmbeddingProviderAdapters = [ ollamaAdapter, ] as const; +const builtinMemoryEmbeddingProviderAdapterById = new Map( + builtinMemoryEmbeddingProviderAdapters.map((adapter) => [adapter.id, adapter]), +); + +export function getBuiltinMemoryEmbeddingProviderAdapter( + id: string, +): MemoryEmbeddingProviderAdapter | undefined { + return builtinMemoryEmbeddingProviderAdapterById.get(id); +} + export function registerBuiltInMemoryEmbeddingProviders(register: { registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void; }): void { diff --git a/package.json b/package.json index f73331243df..d0b18293e1b 100644 --- a/package.json +++ b/package.json @@ -420,6 +420,10 @@ "types": "./dist/plugin-sdk/memory-core.d.ts", "default": "./dist/plugin-sdk/memory-core.js" }, + "./plugin-sdk/memory-core-engine-runtime": { + "types": "./dist/plugin-sdk/memory-core-engine-runtime.d.ts", + "default": "./dist/plugin-sdk/memory-core-engine-runtime.js" + }, "./plugin-sdk/memory-core-host": { "types": "./dist/plugin-sdk/memory-core-host.d.ts", "default": "./dist/plugin-sdk/memory-core-host.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index b38d86fdbe1..ddc2975218b 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -95,6 +95,7 @@ "matrix", "mattermost", "memory-core", + "memory-core-engine-runtime", "memory-core-host", "memory-core-host-engine", "memory-core-host-runtime", diff --git a/src/plugin-sdk/memory-core-engine-runtime.ts b/src/plugin-sdk/memory-core-engine-runtime.ts new file mode 100644 index 00000000000..3e6a940869f --- /dev/null +++ b/src/plugin-sdk/memory-core-engine-runtime.ts @@ -0,0 +1,7 @@ +// Thin engine runtime compat surface for the bundled memory-core plugin. +// Keep extension-owned engine exports isolated behind a dedicated SDK subpath. + +export { + getMemorySearchManager, + MemoryIndexManager, +} from "../../extensions/memory-core/src/memory/index.js"; diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index 5b8ff72f17e..fdc63aa4f97 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -14,10 +14,7 @@ export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; export { loadConfig } from "../config/config.js"; export { resolveStateDir } from "../config/paths.js"; export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; -export { - getMemorySearchManager, - MemoryIndexManager, -} from "../../extensions/memory-core/src/memory/index.js"; +export { getMemorySearchManager, MemoryIndexManager } from "./memory-core-engine-runtime.js"; export { listMemoryFiles, normalizeExtraMemoryPaths, diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts new file mode 100644 index 00000000000..d35339bc203 --- /dev/null +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-providers.js"; +import { createPluginRegistry, type PluginRecord } from "../registry.js"; +import type { PluginRuntime } from "../runtime/types.js"; +import { createPluginRecord } from "../status.test-helpers.js"; +import type { OpenClawPluginApi } from "../types.js"; + +function registerTestPlugin(params: { + registry: ReturnType; + config: OpenClawConfig; + record: PluginRecord; + register(api: OpenClawPluginApi): void; +}) { + params.registry.registry.plugins.push(params.record); + params.register( + params.registry.createApi(params.record, { + config: params.config, + }), + ); +} + +describe("memory embedding provider registration", () => { + it("only allows memory plugins to register adapters", () => { + const config = {} as OpenClawConfig; + const registry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "not-memory", + name: "Not Memory", + source: "/virtual/not-memory/index.ts", + }), + register(api) { + api.registerMemoryEmbeddingProvider({ + id: "forbidden", + create: async () => ({ provider: null }), + }); + }, + }); + + expect(getRegisteredMemoryEmbeddingProvider("forbidden")).toBeUndefined(); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "not-memory", + message: "only memory plugins can register memory embedding providers", + }), + ]), + ); + }); + + it("records the owning memory plugin id for registered adapters", () => { + const config = {} as OpenClawConfig; + const registry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "memory-core", + name: "Memory Core", + kind: "memory", + source: "/virtual/memory-core/index.ts", + }), + register(api) { + api.registerMemoryEmbeddingProvider({ + id: "openai", + create: async () => ({ provider: null }), + }); + }, + }); + + expect(getRegisteredMemoryEmbeddingProvider("openai")).toEqual({ + adapter: expect.objectContaining({ id: "openai" }), + ownerPluginId: "memory-core", + }); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d92a84da578..531ac6d5be7 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -24,8 +24,8 @@ import { clearPluginInteractiveHandlers } from "./interactive.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { clearMemoryEmbeddingProviders, - listMemoryEmbeddingProviders, - restoreMemoryEmbeddingProviders, + listRegisteredMemoryEmbeddingProviders, + restoreRegisteredMemoryEmbeddingProviders, } from "./memory-embedding-providers.js"; import { clearMemoryPluginState, @@ -104,7 +104,7 @@ export class PluginLoadFailureError extends Error { type CachedPluginState = { registry: PluginRegistry; - memoryEmbeddingProviders: ReturnType; + memoryEmbeddingProviders: ReturnType; memoryFlushPlanResolver: ReturnType; memoryPromptBuilder: ReturnType; memoryRuntime: ReturnType; @@ -719,7 +719,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); if (cached) { - restoreMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); + restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); restoreMemoryPluginState({ promptBuilder: cached.memoryPromptBuilder, flushPlanResolver: cached.memoryFlushPlanResolver, @@ -1235,7 +1235,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); - const previousMemoryEmbeddingProviders = listMemoryEmbeddingProviders(); + const previousMemoryEmbeddingProviders = listRegisteredMemoryEmbeddingProviders(); const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver(); const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder(); const previousMemoryRuntime = getMemoryRuntime(); @@ -1252,7 +1252,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Snapshot loads should not replace process-global runtime prompt state. if (!shouldActivate) { - restoreMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); + restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); restoreMemoryPluginState({ promptBuilder: previousMemoryPromptBuilder, flushPlanResolver: previousMemoryFlushPlanResolver, @@ -1262,7 +1262,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); } catch (err) { - restoreMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); + restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); restoreMemoryPluginState({ promptBuilder: previousMemoryPromptBuilder, flushPlanResolver: previousMemoryFlushPlanResolver, @@ -1303,7 +1303,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { setCachedPluginRegistry(cacheKey, { registry, - memoryEmbeddingProviders: listMemoryEmbeddingProviders(), + memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(), memoryFlushPlanResolver: getMemoryFlushPlanResolver(), memoryPromptBuilder: getMemoryPromptSectionBuilder(), memoryRuntime: getMemoryRuntime(), diff --git a/src/plugins/memory-embedding-providers.test.ts b/src/plugins/memory-embedding-providers.test.ts index 003a79d7914..6485cc16798 100644 --- a/src/plugins/memory-embedding-providers.test.ts +++ b/src/plugins/memory-embedding-providers.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, expect, it } from "vitest"; import { clearMemoryEmbeddingProviders, getMemoryEmbeddingProvider, + getRegisteredMemoryEmbeddingProvider, listMemoryEmbeddingProviders, + listRegisteredMemoryEmbeddingProviders, registerMemoryEmbeddingProvider, + restoreRegisteredMemoryEmbeddingProviders, restoreMemoryEmbeddingProviders, type MemoryEmbeddingProviderAdapter, } from "./memory-embedding-providers.js"; @@ -39,6 +42,38 @@ describe("memory embedding provider registry", () => { expect(getMemoryEmbeddingProvider("beta")).toBe(beta); }); + it("tracks owner plugin ids in registered snapshots", () => { + const alpha = createAdapter("alpha"); + registerMemoryEmbeddingProvider(alpha, { ownerPluginId: "memory-core" }); + + expect(getRegisteredMemoryEmbeddingProvider("alpha")).toEqual({ + adapter: alpha, + ownerPluginId: "memory-core", + }); + expect(listRegisteredMemoryEmbeddingProviders()).toEqual([ + { + adapter: alpha, + ownerPluginId: "memory-core", + }, + ]); + }); + + it("restores registered snapshots with owner metadata", () => { + const beta = createAdapter("beta"); + + restoreRegisteredMemoryEmbeddingProviders([ + { + adapter: beta, + ownerPluginId: "memory-core", + }, + ]); + + expect(getRegisteredMemoryEmbeddingProvider("beta")).toEqual({ + adapter: beta, + ownerPluginId: "memory-core", + }); + }); + it("clears the registry", () => { registerMemoryEmbeddingProvider(createAdapter("alpha")); diff --git a/src/plugins/memory-embedding-providers.ts b/src/plugins/memory-embedding-providers.ts index 5d7208189bb..1ab4eec4c86 100644 --- a/src/plugins/memory-embedding-providers.ts +++ b/src/plugins/memory-embedding-providers.ts @@ -67,24 +67,56 @@ export type MemoryEmbeddingProviderAdapter = { shouldContinueAutoSelection?: (err: unknown) => boolean; }; -const memoryEmbeddingProviders = new Map(); +export type RegisteredMemoryEmbeddingProvider = { + adapter: MemoryEmbeddingProviderAdapter; + ownerPluginId?: string; +}; -export function registerMemoryEmbeddingProvider(adapter: MemoryEmbeddingProviderAdapter): void { - memoryEmbeddingProviders.set(adapter.id, adapter); +const memoryEmbeddingProviders = new Map(); + +export function registerMemoryEmbeddingProvider( + adapter: MemoryEmbeddingProviderAdapter, + options?: { ownerPluginId?: string }, +): void { + memoryEmbeddingProviders.set(adapter.id, { + adapter, + ownerPluginId: options?.ownerPluginId, + }); } -export function getMemoryEmbeddingProvider(id: string): MemoryEmbeddingProviderAdapter | undefined { +export function getRegisteredMemoryEmbeddingProvider( + id: string, +): RegisteredMemoryEmbeddingProvider | undefined { return memoryEmbeddingProviders.get(id); } -export function listMemoryEmbeddingProviders(): MemoryEmbeddingProviderAdapter[] { +export function getMemoryEmbeddingProvider(id: string): MemoryEmbeddingProviderAdapter | undefined { + return memoryEmbeddingProviders.get(id)?.adapter; +} + +export function listRegisteredMemoryEmbeddingProviders(): RegisteredMemoryEmbeddingProvider[] { return Array.from(memoryEmbeddingProviders.values()); } +export function listMemoryEmbeddingProviders(): MemoryEmbeddingProviderAdapter[] { + return listRegisteredMemoryEmbeddingProviders().map((entry) => entry.adapter); +} + export function restoreMemoryEmbeddingProviders(adapters: MemoryEmbeddingProviderAdapter[]): void { memoryEmbeddingProviders.clear(); for (const adapter of adapters) { - memoryEmbeddingProviders.set(adapter.id, adapter); + registerMemoryEmbeddingProvider(adapter); + } +} + +export function restoreRegisteredMemoryEmbeddingProviders( + entries: RegisteredMemoryEmbeddingProvider[], +): void { + memoryEmbeddingProviders.clear(); + for (const entry of entries) { + registerMemoryEmbeddingProvider(entry.adapter, { + ownerPluginId: entry.ownerPluginId, + }); } } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 2fd4582e29f..7bdf2199d10 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -15,7 +15,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; import { - getMemoryEmbeddingProvider, + getRegisteredMemoryEmbeddingProvider, registerMemoryEmbeddingProvider, } from "./memory-embedding-providers.js"; import { @@ -1106,17 +1106,29 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (registrationMode !== "full") { return; } - const existing = getMemoryEmbeddingProvider(adapter.id); - if (existing) { + if (record.kind !== "memory") { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `memory embedding provider already registered: ${adapter.id}`, + message: "only memory plugins can register memory embedding providers", }); return; } - registerMemoryEmbeddingProvider(adapter); + const existing = getRegisteredMemoryEmbeddingProvider(adapter.id); + if (existing) { + const ownerDetail = existing.ownerPluginId ? ` (owner: ${existing.ownerPluginId})` : ""; + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `memory embedding provider already registered: ${adapter.id}${ownerDetail}`, + }); + return; + } + registerMemoryEmbeddingProvider(adapter, { + ownerPluginId: record.id, + }); }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) =>