From dbf78de7c680405245cadee06a8ddb9e787616aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 00:40:45 +0000 Subject: [PATCH] refactor: move memory engine behind plugin adapters --- extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/memory-core/index.test.ts | 3 + extensions/memory-core/index.ts | 2 + extensions/memory-core/src/api.ts | 2 +- extensions/memory-core/src/engine-host-api.ts | 1 + .../memory-core/src/memory/backend-config.ts | 1 + .../memory/embedding-manager.test-harness.ts | 8 +- .../src}/memory/embedding.test-mocks.ts | 0 .../src/memory/embeddings-ollama.ts | 1 + .../memory-core/src/memory/embeddings.ts | 175 +++++++++ .../memory-core/src}/memory/hybrid.test.ts | 6 +- .../memory-core/src}/memory/index.test.ts | 23 +- extensions/memory-core/src/memory/index.ts | 2 +- extensions/memory-core/src/memory/internal.ts | 1 + .../src/memory/manager-embedding-ops.ts | 313 +++------------ .../memory-core/src/memory/manager-search.ts | 2 +- .../src/memory/manager-sync-ops.ts | 61 +-- .../src}/memory/manager.async-search.test.ts | 6 +- .../memory/manager.atomic-reindex.test.ts | 9 +- .../src}/memory/manager.batch.test.ts | 11 +- .../memory/manager.embedding-batches.test.ts | 2 +- .../memory/manager.get-concurrency.test.ts | 13 +- .../memory/manager.mistral-provider.test.ts | 88 ++--- .../src}/memory/manager.read-file.test.ts | 4 +- .../memory/manager.readonly-recovery.test.ts | 4 +- .../manager.sync-errors-do-not-crash.test.ts | 2 +- extensions/memory-core/src/memory/manager.ts | 33 +- .../src}/memory/manager.vector-dedupe.test.ts | 9 +- .../memory/manager.watcher-config.test.ts | 11 +- .../memory-core/src}/memory/mmr.test.ts | 2 +- .../src/memory/provider-adapters.ts | 359 ++++++++++++++++++ .../src}/memory/qmd-manager.test.ts | 38 +- .../memory-core/src/memory/qmd-manager.ts | 4 +- .../src}/memory/search-manager.test.ts | 11 +- .../memory-core/src/memory/search-manager.ts | 2 +- .../memory-core/src/memory/sqlite-vec.ts | 1 + extensions/memory-core/src/memory/sqlite.ts | 1 + .../src}/memory/temporal-decay.test.ts | 4 +- .../src/memory/test-embeddings-mock.ts | 64 ++++ .../src/memory/test-helpers/ssrf.ts | 34 ++ .../src}/memory/test-manager-helpers.ts | 6 +- .../memory-core/src}/memory/test-manager.ts | 5 +- .../src}/memory/test-runtime-mocks.ts | 0 package.json | 8 + scripts/lib/plugin-sdk-entrypoints.json | 2 + src/agents/memory-search.test.ts | 61 ++- src/agents/memory-search.ts | 47 +-- .../pi-extensions/compaction-safeguard.ts | 5 +- src/commands/doctor-memory-search.ts | 6 +- src/commands/status.command.ts | 2 +- src/commands/status.scan.deps.runtime.ts | 2 +- src/commands/status.scan.shared.ts | 2 +- src/config/schema.base.generated.ts | 116 +----- src/config/types.tools.ts | 8 +- src/config/zod-schema.agent-runtime.ts | 23 +- src/gateway/embeddings-http.test.ts | 65 +++- src/gateway/embeddings-http.ts | 158 +++++--- src/memory/test-embeddings-mock.ts | 19 - src/plugin-sdk/memory-core-host-engine.ts | 168 ++++++++ src/plugin-sdk/memory-core-host-runtime.ts | 38 ++ src/plugin-sdk/memory-core-host.ts | 155 +------- src/plugin-sdk/memory-core.ts | 11 +- src/plugins/captured-registration.ts | 3 +- src/plugins/loader.test.ts | 25 ++ src/plugins/loader.ts | 12 + .../memory-embedding-providers.test.ts | 49 +++ src/plugins/memory-embedding-providers.ts | 95 +++++ .../memory-host}/backend-config.test.ts | 4 +- .../memory-host}/backend-config.ts | 14 +- .../memory-host}/batch-embedding-common.ts | 0 .../memory-host}/batch-error-utils.test.ts | 0 .../memory-host}/batch-error-utils.ts | 0 .../memory-host}/batch-gemini.test.ts | 0 .../memory-host}/batch-gemini.ts | 0 .../memory-host}/batch-http.test.ts | 8 +- .../memory-host}/batch-http.ts | 4 +- .../memory-host}/batch-openai.ts | 0 .../memory-host}/batch-output.test.ts | 0 .../memory-host}/batch-output.ts | 0 .../memory-host}/batch-provider-common.ts | 0 .../memory-host}/batch-runner.ts | 0 .../memory-host}/batch-status.test.ts | 0 .../memory-host}/batch-status.ts | 0 .../memory-host}/batch-upload.ts | 0 .../memory-host}/batch-utils.ts | 2 +- .../memory-host}/batch-voyage.test.ts | 0 .../memory-host}/batch-voyage.ts | 0 .../embedding-chunk-limits.test.ts | 0 .../memory-host}/embedding-chunk-limits.ts | 0 .../memory-host}/embedding-input-limits.ts | 0 .../memory-host}/embedding-inputs.ts | 0 .../memory-host}/embedding-model-limits.ts | 0 .../memory-host}/embedding-vectors.ts | 0 .../memory-host}/embeddings-debug.ts | 4 +- .../memory-host}/embeddings-gemini.test.ts | 6 +- .../memory-host}/embeddings-gemini.ts | 10 +- .../memory-host}/embeddings-mistral.test.ts | 0 .../memory-host}/embeddings-mistral.ts | 2 +- .../embeddings-model-normalize.test.ts | 0 .../embeddings-model-normalize.ts | 0 .../memory-host}/embeddings-ollama.test.ts | 2 +- .../memory-host}/embeddings-ollama.ts | 10 +- .../memory-host}/embeddings-openai.ts | 4 +- .../memory-host}/embeddings-remote-client.ts | 4 +- .../embeddings-remote-fetch.test.ts | 0 .../memory-host}/embeddings-remote-fetch.ts | 2 +- .../embeddings-remote-provider.ts | 2 +- .../memory-host}/embeddings-voyage.test.ts | 10 +- .../memory-host}/embeddings-voyage.ts | 2 +- .../memory-host}/embeddings.test.ts | 6 +- .../memory-host}/embeddings.ts | 10 +- .../memory-host}/fs-utils.ts | 0 .../memory-host}/internal.test.ts | 0 .../memory-host}/internal.ts | 4 +- .../memory-host}/memory-schema.ts | 0 .../memory-host}/multimodal.ts | 0 .../memory-host}/node-llama.ts | 0 .../memory-host}/post-json.test.ts | 0 .../memory-host}/post-json.ts | 2 +- .../memory-host}/qmd-process.test.ts | 0 .../memory-host}/qmd-process.ts | 2 +- .../memory-host}/qmd-query-parser.test.ts | 0 .../memory-host}/qmd-query-parser.ts | 2 +- .../memory-host}/qmd-scope.test.ts | 0 .../memory-host}/qmd-scope.ts | 2 +- .../memory-host}/query-expansion.test.ts | 0 .../memory-host}/query-expansion.ts | 0 .../memory-host}/read-file.ts | 6 +- .../memory-host}/remote-http.ts | 4 +- .../memory-host}/secret-input.ts | 2 +- .../memory-host}/session-files.test.ts | 0 .../memory-host}/session-files.ts | 6 +- .../memory-host}/sqlite-vec.ts | 0 src/{memory => plugins/memory-host}/sqlite.ts | 2 +- .../memory-host}/status-format.ts | 0 .../memory-host}/test-helpers/ssrf.ts | 2 +- src/{memory => plugins/memory-host}/types.ts | 0 src/plugins/memory-state.ts | 2 +- src/plugins/registry.ts | 20 + src/plugins/types.ts | 4 + test/helpers/extensions/plugin-api.ts | 1 + test/helpers/memory-tool-manager-mock.ts | 4 +- 142 files changed, 1610 insertions(+), 966 deletions(-) create mode 100644 extensions/memory-core/src/engine-host-api.ts create mode 100644 extensions/memory-core/src/memory/backend-config.ts rename {src => extensions/memory-core/src}/memory/embedding-manager.test-harness.ts (93%) rename {src => extensions/memory-core/src}/memory/embedding.test-mocks.ts (100%) create mode 100644 extensions/memory-core/src/memory/embeddings-ollama.ts create mode 100644 extensions/memory-core/src/memory/embeddings.ts rename {src => extensions/memory-core/src}/memory/hybrid.test.ts (95%) rename {src => extensions/memory-core/src}/memory/index.test.ts (98%) create mode 100644 extensions/memory-core/src/memory/internal.ts rename {src => extensions/memory-core/src}/memory/manager.async-search.test.ts (94%) rename {src => extensions/memory-core/src}/memory/manager.atomic-reindex.test.ts (90%) rename {src => extensions/memory-core/src}/memory/manager.batch.test.ts (96%) rename {src => extensions/memory-core/src}/memory/manager.embedding-batches.test.ts (98%) rename {src => extensions/memory-core/src}/memory/manager.get-concurrency.test.ts (88%) rename {src => extensions/memory-core/src}/memory/manager.mistral-provider.test.ts (74%) rename {src => extensions/memory-core/src}/memory/manager.read-file.test.ts (96%) rename {src => extensions/memory-core/src}/memory/manager.readonly-recovery.test.ts (97%) rename {src => extensions/memory-core/src}/memory/manager.sync-errors-do-not-crash.test.ts (90%) rename {src => extensions/memory-core/src}/memory/manager.vector-dedupe.test.ts (92%) rename {src => extensions/memory-core/src}/memory/manager.watcher-config.test.ts (91%) rename {src => extensions/memory-core/src}/memory/mmr.test.ts (99%) create mode 100644 extensions/memory-core/src/memory/provider-adapters.ts rename {src => extensions/memory-core/src}/memory/qmd-manager.test.ts (99%) rename {src => extensions/memory-core/src}/memory/search-manager.test.ts (97%) create mode 100644 extensions/memory-core/src/memory/sqlite-vec.ts create mode 100644 extensions/memory-core/src/memory/sqlite.ts rename {src => extensions/memory-core/src}/memory/temporal-decay.test.ts (97%) create mode 100644 extensions/memory-core/src/memory/test-embeddings-mock.ts create mode 100644 extensions/memory-core/src/memory/test-helpers/ssrf.ts rename {src => extensions/memory-core/src}/memory/test-manager-helpers.ts (74%) rename {src => extensions/memory-core/src}/memory/test-manager.ts (61%) rename {src => extensions/memory-core/src}/memory/test-runtime-mocks.ts (100%) delete mode 100644 src/memory/test-embeddings-mock.ts create mode 100644 src/plugin-sdk/memory-core-host-engine.ts create mode 100644 src/plugin-sdk/memory-core-host-runtime.ts create mode 100644 src/plugins/memory-embedding-providers.test.ts create mode 100644 src/plugins/memory-embedding-providers.ts rename src/{memory => plugins/memory-host}/backend-config.test.ts (97%) rename src/{memory => plugins/memory-host}/backend-config.ts (96%) rename src/{memory => plugins/memory-host}/batch-embedding-common.ts (100%) rename src/{memory => plugins/memory-host}/batch-error-utils.test.ts (100%) rename src/{memory => plugins/memory-host}/batch-error-utils.ts (100%) rename src/{memory => plugins/memory-host}/batch-gemini.test.ts (100%) rename src/{memory => plugins/memory-host}/batch-gemini.ts (100%) rename src/{memory => plugins/memory-host}/batch-http.test.ts (92%) rename src/{memory => plugins/memory-host}/batch-http.ts (88%) rename src/{memory => plugins/memory-host}/batch-openai.ts (100%) rename src/{memory => plugins/memory-host}/batch-output.test.ts (100%) rename src/{memory => plugins/memory-host}/batch-output.ts (100%) rename src/{memory => plugins/memory-host}/batch-provider-common.ts (100%) rename src/{memory => plugins/memory-host}/batch-runner.ts (100%) rename src/{memory => plugins/memory-host}/batch-status.test.ts (100%) rename src/{memory => plugins/memory-host}/batch-status.ts (100%) rename src/{memory => plugins/memory-host}/batch-upload.ts (100%) rename src/{memory => plugins/memory-host}/batch-utils.ts (94%) rename src/{memory => plugins/memory-host}/batch-voyage.test.ts (100%) rename src/{memory => plugins/memory-host}/batch-voyage.ts (100%) rename src/{memory => plugins/memory-host}/embedding-chunk-limits.test.ts (100%) rename src/{memory => plugins/memory-host}/embedding-chunk-limits.ts (100%) rename src/{memory => plugins/memory-host}/embedding-input-limits.ts (100%) rename src/{memory => plugins/memory-host}/embedding-inputs.ts (100%) rename src/{memory => plugins/memory-host}/embedding-model-limits.ts (100%) rename src/{memory => plugins/memory-host}/embedding-vectors.ts (100%) rename src/{memory => plugins/memory-host}/embeddings-debug.ts (75%) rename src/{memory => plugins/memory-host}/embeddings-gemini.test.ts (98%) rename src/{memory => plugins/memory-host}/embeddings-gemini.ts (97%) rename src/{memory => plugins/memory-host}/embeddings-mistral.test.ts (100%) rename src/{memory => plugins/memory-host}/embeddings-mistral.ts (96%) rename src/{memory => plugins/memory-host}/embeddings-model-normalize.test.ts (100%) rename src/{memory => plugins/memory-host}/embeddings-model-normalize.ts (100%) rename src/{memory => plugins/memory-host}/embeddings-ollama.test.ts (98%) rename src/{memory => plugins/memory-host}/embeddings-ollama.ts (91%) rename src/{memory => plugins/memory-host}/embeddings-openai.ts (91%) rename src/{memory => plugins/memory-host}/embeddings-remote-client.ts (91%) rename src/{memory => plugins/memory-host}/embeddings-remote-fetch.test.ts (100%) rename src/{memory => plugins/memory-host}/embeddings-remote-fetch.ts (91%) rename src/{memory => plugins/memory-host}/embeddings-remote-provider.ts (96%) rename src/{memory => plugins/memory-host}/embeddings-voyage.test.ts (93%) rename src/{memory => plugins/memory-host}/embeddings-voyage.ts (97%) rename src/{memory => plugins/memory-host}/embeddings.test.ts (99%) rename src/{memory => plugins/memory-host}/embeddings.ts (97%) rename src/{memory => plugins/memory-host}/fs-utils.ts (100%) rename src/{memory => plugins/memory-host}/internal.test.ts (100%) rename src/{memory => plugins/memory-host}/internal.ts (98%) rename src/{memory => plugins/memory-host}/memory-schema.ts (100%) rename src/{memory => plugins/memory-host}/multimodal.ts (100%) rename src/{memory => plugins/memory-host}/node-llama.ts (100%) rename src/{memory => plugins/memory-host}/post-json.test.ts (100%) rename src/{memory => plugins/memory-host}/post-json.ts (93%) rename src/{memory => plugins/memory-host}/qmd-process.test.ts (100%) rename src/{memory => plugins/memory-host}/qmd-process.ts (98%) rename src/{memory => plugins/memory-host}/qmd-query-parser.test.ts (100%) rename src/{memory => plugins/memory-host}/qmd-query-parser.ts (98%) rename src/{memory => plugins/memory-host}/qmd-scope.test.ts (100%) rename src/{memory => plugins/memory-host}/qmd-scope.ts (97%) rename src/{memory => plugins/memory-host}/query-expansion.test.ts (100%) rename src/{memory => plugins/memory-host}/query-expansion.ts (100%) rename src/{memory => plugins/memory-host}/read-file.ts (93%) rename src/{memory => plugins/memory-host}/remote-http.ts (89%) rename src/{memory => plugins/memory-host}/secret-input.ts (91%) rename src/{memory => plugins/memory-host}/session-files.test.ts (100%) rename src/{memory => plugins/memory-host}/session-files.ts (94%) rename src/{memory => plugins/memory-host}/sqlite-vec.ts (100%) rename src/{memory => plugins/memory-host}/sqlite.ts (89%) rename src/{memory => plugins/memory-host}/status-format.ts (100%) rename src/{memory => plugins/memory-host}/test-helpers/ssrf.ts (89%) rename src/{memory => plugins/memory-host}/types.ts (100%) diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index d57eaa84821..de3f2cc2cae 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -59,6 +59,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerMemoryPromptSection() {}, registerMemoryFlushPlan() {}, registerMemoryRuntime() {}, + registerMemoryEmbeddingProvider() {}, on() {}, resolvePath: (p) => p, ...overrides, diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index db0310f13c5..3c166e1df79 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -59,12 +59,14 @@ describe("plugin registration", () => { const registerMemoryPromptSection = vi.fn(); const registerMemoryFlushPlan = vi.fn(); const registerMemoryRuntime = vi.fn(); + const registerMemoryEmbeddingProvider = vi.fn(); const registerCli = vi.fn(); const api = { registerTool, registerMemoryPromptSection, registerMemoryFlushPlan, registerMemoryRuntime, + registerMemoryEmbeddingProvider, registerCli, }; @@ -73,6 +75,7 @@ describe("plugin registration", () => { expect(registerMemoryPromptSection).toHaveBeenCalledWith(buildPromptSection); expect(registerMemoryFlushPlan).toHaveBeenCalledWith(buildMemoryFlushPlan); expect(registerMemoryRuntime).toHaveBeenCalledWith(memoryRuntime); + expect(registerMemoryEmbeddingProvider).toHaveBeenCalledTimes(6); expect(registerTool).toHaveBeenCalledTimes(2); expect(registerTool.mock.calls[0]?.[1]).toEqual({ names: ["memory_search"] }); expect(registerTool.mock.calls[1]?.[1]).toEqual({ names: ["memory_get"] }); diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 21fe44564ac..2623c211021 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -6,6 +6,7 @@ import { DEFAULT_MEMORY_FLUSH_PROMPT, DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, } from "./src/flush-plan.js"; +import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js"; import { buildPromptSection } from "./src/prompt-section.js"; import { memoryRuntime } from "./src/runtime-provider.js"; import { createMemoryGetTool, createMemorySearchTool } from "./src/tools.js"; @@ -23,6 +24,7 @@ export default definePluginEntry({ description: "File-backed memory search tools and CLI", kind: "memory", register(api) { + registerBuiltInMemoryEmbeddingProviders(api); api.registerMemoryPromptSection(buildPromptSection); api.registerMemoryFlushPlan(buildMemoryFlushPlan); api.registerMemoryRuntime(memoryRuntime); diff --git a/extensions/memory-core/src/api.ts b/extensions/memory-core/src/api.ts index a708ca09e10..b72ee0bee03 100644 --- a/extensions/memory-core/src/api.ts +++ b/extensions/memory-core/src/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/memory-core-host"; +export * from "openclaw/plugin-sdk/memory-core-host-runtime"; diff --git a/extensions/memory-core/src/engine-host-api.ts b/extensions/memory-core/src/engine-host-api.ts new file mode 100644 index 00000000000..fceaddc09f4 --- /dev/null +++ b/extensions/memory-core/src/engine-host-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/memory-core-host-engine"; diff --git a/extensions/memory-core/src/memory/backend-config.ts b/extensions/memory-core/src/memory/backend-config.ts new file mode 100644 index 00000000000..856d8a7c806 --- /dev/null +++ b/extensions/memory-core/src/memory/backend-config.ts @@ -0,0 +1 @@ +export { resolveMemoryBackendConfig } from "../engine-host-api.js"; diff --git a/src/memory/embedding-manager.test-harness.ts b/extensions/memory-core/src/memory/embedding-manager.test-harness.ts similarity index 93% rename from src/memory/embedding-manager.test-harness.ts rename to extensions/memory-core/src/memory/embedding-manager.test-harness.ts index 2b1cfdebb6c..4b22f7fb822 100644 --- a/src/memory/embedding-manager.test-harness.ts +++ b/extensions/memory-core/src/memory/embedding-manager.test-harness.ts @@ -2,11 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { MemoryIndexManager } from "../plugin-sdk/memory-core.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import type { MemoryIndexManager, MemorySearchManager } from "./index.js"; type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js"); -type MemoryIndexModule = typeof import("../plugin-sdk/memory-core.js"); +type MemoryIndexModule = typeof import("./index.js"); type MemorySearchManagerHandle = Awaited< ReturnType >["manager"]; @@ -64,7 +64,7 @@ export function installEmbeddingManagerFixture(opts: { const embeddingMocks = await import("./embedding.test-mocks.js"); embedBatch = embeddingMocks.getEmbedBatchMock(); resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks; - ({ getMemorySearchManager } = await import("../plugin-sdk/memory-core.js")); + ({ getMemorySearchManager } = await import("./index.js")); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), opts.fixturePrefix)); workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); diff --git a/src/memory/embedding.test-mocks.ts b/extensions/memory-core/src/memory/embedding.test-mocks.ts similarity index 100% rename from src/memory/embedding.test-mocks.ts rename to extensions/memory-core/src/memory/embedding.test-mocks.ts diff --git a/extensions/memory-core/src/memory/embeddings-ollama.ts b/extensions/memory-core/src/memory/embeddings-ollama.ts new file mode 100644 index 00000000000..486297a4880 --- /dev/null +++ b/extensions/memory-core/src/memory/embeddings-ollama.ts @@ -0,0 +1 @@ +export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings.js"; diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts new file mode 100644 index 00000000000..b88de764228 --- /dev/null +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -0,0 +1,175 @@ +import { + DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LOCAL_MODEL, + DEFAULT_MISTRAL_EMBEDDING_MODEL, + DEFAULT_OLLAMA_EMBEDDING_MODEL, + DEFAULT_OPENAI_EMBEDDING_MODEL, + DEFAULT_VOYAGE_EMBEDDING_MODEL, + getMemoryEmbeddingProvider, + listMemoryEmbeddingProviders, + type MemoryEmbeddingProvider, + type MemoryEmbeddingProviderAdapter, + type MemoryEmbeddingProviderCreateOptions, + type MemoryEmbeddingProviderRuntime, +} from "../engine-host-api.js"; +import { canAutoSelectLocal } from "./provider-adapters.js"; + +export { + DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LOCAL_MODEL, + DEFAULT_MISTRAL_EMBEDDING_MODEL, + DEFAULT_OLLAMA_EMBEDDING_MODEL, + DEFAULT_OPENAI_EMBEDDING_MODEL, + DEFAULT_VOYAGE_EMBEDDING_MODEL, +} from "../engine-host-api.js"; + +export type EmbeddingProvider = MemoryEmbeddingProvider; +export type EmbeddingProviderId = string; +export type EmbeddingProviderRequest = string; +export type EmbeddingProviderFallback = string; +export type EmbeddingProviderRuntime = MemoryEmbeddingProviderRuntime; + +export type EmbeddingProviderResult = { + provider: EmbeddingProvider | null; + requestedProvider: EmbeddingProviderRequest; + fallbackFrom?: string; + fallbackReason?: string; + providerUnavailableReason?: string; + runtime?: EmbeddingProviderRuntime; +}; + +type CreateEmbeddingProviderOptions = MemoryEmbeddingProviderCreateOptions & { + provider: EmbeddingProviderRequest; + fallback: EmbeddingProviderFallback; +}; + +function formatErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function formatProviderError(adapter: MemoryEmbeddingProviderAdapter, err: unknown): string { + return adapter.formatSetupError?.(err) ?? formatErrorMessage(err); +} + +function shouldContinueAutoSelection( + adapter: MemoryEmbeddingProviderAdapter, + err: unknown, +): boolean { + return adapter.shouldContinueAutoSelection?.(err) ?? false; +} + +function getAdapter(id: string): MemoryEmbeddingProviderAdapter { + const adapter = getMemoryEmbeddingProvider(id); + if (!adapter) { + throw new Error(`Unknown memory embedding provider: ${id}`); + } + return adapter; +} + +function listAutoSelectAdapters( + options: CreateEmbeddingProviderOptions, +): MemoryEmbeddingProviderAdapter[] { + return listMemoryEmbeddingProviders() + .filter((adapter) => typeof adapter.autoSelectPriority === "number") + .filter((adapter) => + adapter.id === "local" ? canAutoSelectLocal(options.local?.modelPath) : true, + ) + .toSorted( + (a, b) => + (a.autoSelectPriority ?? Number.MAX_SAFE_INTEGER) - + (b.autoSelectPriority ?? Number.MAX_SAFE_INTEGER), + ); +} + +function resolveProviderModel( + adapter: MemoryEmbeddingProviderAdapter, + requestedModel: string, +): string { + const trimmed = requestedModel.trim(); + if (trimmed) { + return trimmed; + } + return adapter.defaultModel ?? ""; +} + +async function createWithAdapter( + adapter: MemoryEmbeddingProviderAdapter, + options: CreateEmbeddingProviderOptions, +): Promise { + const result = await adapter.create({ + ...options, + model: resolveProviderModel(adapter, options.model), + }); + return { + provider: result.provider, + requestedProvider: options.provider, + runtime: result.runtime, + }; +} + +export async function createEmbeddingProvider( + options: CreateEmbeddingProviderOptions, +): Promise { + if (options.provider === "auto") { + const reasons: string[] = []; + for (const adapter of listAutoSelectAdapters(options)) { + try { + const result = await createWithAdapter(adapter, { + ...options, + provider: adapter.id, + }); + return { + ...result, + requestedProvider: "auto", + }; + } catch (err) { + const message = formatProviderError(adapter, err); + if (shouldContinueAutoSelection(adapter, err)) { + reasons.push(message); + continue; + } + const wrapped = new Error(message) as Error & { cause?: unknown }; + wrapped.cause = err; + throw wrapped; + } + } + return { + provider: null, + requestedProvider: "auto", + providerUnavailableReason: + reasons.length > 0 ? reasons.join("\n\n") : "No embeddings provider available.", + }; + } + + const primaryAdapter = getAdapter(options.provider); + try { + return await createWithAdapter(primaryAdapter, options); + } catch (primaryErr) { + const reason = formatProviderError(primaryAdapter, primaryErr); + if (options.fallback && options.fallback !== "none" && options.fallback !== options.provider) { + const fallbackAdapter = getAdapter(options.fallback); + try { + const fallbackResult = await createWithAdapter(fallbackAdapter, { + ...options, + provider: options.fallback, + }); + return { + ...fallbackResult, + requestedProvider: options.provider, + fallbackFrom: options.provider, + fallbackReason: reason, + }; + } catch (fallbackErr) { + const fallbackReason = formatProviderError(fallbackAdapter, fallbackErr); + const wrapped = new Error( + `${reason}\n\nFallback to ${options.fallback} failed: ${fallbackReason}`, + ) as Error & { cause?: unknown }; + wrapped.cause = primaryErr; + throw wrapped; + } + } + const wrapped = new Error(reason) as Error & { cause?: unknown }; + wrapped.cause = primaryErr; + throw wrapped; + } +} diff --git a/src/memory/hybrid.test.ts b/extensions/memory-core/src/memory/hybrid.test.ts similarity index 95% rename from src/memory/hybrid.test.ts rename to extensions/memory-core/src/memory/hybrid.test.ts index d15fec95944..134e7bfe7eb 100644 --- a/src/memory/hybrid.test.ts +++ b/extensions/memory-core/src/memory/hybrid.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - bm25RankToScore, - buildFtsQuery, - mergeHybridResults, -} from "../../extensions/memory-core/src/memory/hybrid.js"; +import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js"; describe("memory hybrid helpers", () => { it("buildFtsQuery tokenizes and AND-joins", () => { diff --git a/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts similarity index 98% rename from src/memory/index.test.ts rename to extensions/memory-core/src/memory/index.test.ts index d12edbbe172..2a6f1f61b9b 100644 --- a/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -5,9 +5,9 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-runtime-mocks.js"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; +import type { MemoryIndexManager } from "./index.js"; -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); +type MemoryIndexModule = typeof import("./index.js"); let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; @@ -81,13 +81,15 @@ vi.mock("./embeddings.js", () => { }, ...(providerId === "gemini" ? { - gemini: { - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - headers: {}, - model, - modelPath: `models/${model}`, - apiKeys: ["test-key"], - outputDimensionality: options.outputDimensionality, + runtime: { + id: "gemini", + cacheKeyData: { + provider: "gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + model, + outputDimensionality: options.outputDimensionality, + headers: [], + }, }, } : {}), @@ -131,8 +133,7 @@ describe("memory index", () => { beforeAll(async () => { vi.resetModules(); await import("./test-runtime-mocks.js"); - ({ getMemorySearchManager, closeAllMemorySearchManagers } = - await import("../../extensions/memory-core/src/memory/index.js")); + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-")); workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); diff --git a/extensions/memory-core/src/memory/index.ts b/extensions/memory-core/src/memory/index.ts index c189010134f..5111806521a 100644 --- a/extensions/memory-core/src/memory/index.ts +++ b/extensions/memory-core/src/memory/index.ts @@ -3,7 +3,7 @@ export type { MemoryEmbeddingProbeResult, MemorySearchManager, MemorySearchResult, -} from "../api.js"; +} from "../engine-host-api.js"; export { closeAllMemorySearchManagers, getMemorySearchManager, diff --git a/extensions/memory-core/src/memory/internal.ts b/extensions/memory-core/src/memory/internal.ts new file mode 100644 index 00000000000..40e763597b2 --- /dev/null +++ b/extensions/memory-core/src/memory/internal.ts @@ -0,0 +1 @@ +export { buildFileEntry } from "../engine-host-api.js"; diff --git a/extensions/memory-core/src/memory/manager-embedding-ops.ts b/extensions/memory-core/src/memory/manager-embedding-ops.ts index 805852d72a5..baef4fe2543 100644 --- a/extensions/memory-core/src/memory/manager-embedding-ops.ts +++ b/extensions/memory-core/src/memory/manager-embedding-ops.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import { - buildGeminiEmbeddingRequest, buildMultimodalChunkForIndexing, chunkMarkdown, createSubsystemLogger, @@ -11,19 +10,12 @@ import { hashText, parseEmbedding, remapChunkLines, - runGeminiEmbeddingBatches, - runOpenAiEmbeddingBatches, - runVoyageEmbeddingBatches, type EmbeddingInput, - type GeminiBatchRequest, type MemoryChunk, type MemoryFileEntry, type MemorySource, - type OpenAiBatchRequest, type SessionFileEntry, - type VoyageBatchRequest, - OPENAI_BATCH_ENDPOINT, -} from "../api.js"; +} from "../engine-host-api.js"; import { MemoryManagerSyncOps } from "./manager-sync-ops.js"; const VECTOR_TABLE = "chunks_vec"; @@ -229,59 +221,67 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { if (!this.provider) { return hashText(JSON.stringify({ provider: "none", model: "fts-only" })); } - if (this.provider.id === "openai" && this.openAi) { - const entries = Object.entries(this.openAi.headers) - .filter(([key]) => key.toLowerCase() !== "authorization") - .toSorted(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => [key, value]); - return hashText( - JSON.stringify({ - provider: "openai", - baseUrl: this.openAi.baseUrl, - model: this.openAi.model, - headers: entries, - }), - ); - } - if (this.provider.id === "gemini" && this.gemini) { - const entries = Object.entries(this.gemini.headers) - .filter(([key]) => { - const lower = key.toLowerCase(); - return lower !== "authorization" && lower !== "x-goog-api-key"; - }) - .toSorted(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => [key, value]); - return hashText( - JSON.stringify({ - provider: "gemini", - baseUrl: this.gemini.baseUrl, - model: this.gemini.model, - outputDimensionality: this.gemini.outputDimensionality, - headers: entries, - }), - ); + if (this.providerRuntime?.cacheKeyData) { + return hashText(JSON.stringify(this.providerRuntime.cacheKeyData)); } return hashText(JSON.stringify({ provider: this.provider.id, model: this.provider.model })); } + private buildBatchDebug(source: MemorySource, chunks: MemoryChunk[]) { + return (message: string, data?: Record) => + log.debug( + message, + data ? { ...data, source, chunks: chunks.length } : { source, chunks: chunks.length }, + ); + } + private async embedChunksWithBatch( chunks: MemoryChunk[], - entry: MemoryFileEntry | SessionFileEntry, + _entry: MemoryFileEntry | SessionFileEntry, source: MemorySource, ): Promise { - if (!this.provider) { + const batchEmbed = this.providerRuntime?.batchEmbed; + if (!this.provider || !batchEmbed) { return this.embedChunksInBatches(chunks); } - if (this.provider.id === "openai" && this.openAi) { - return this.embedChunksWithOpenAiBatch(chunks, entry, source); + if (chunks.length === 0) { + return []; } - if (this.provider.id === "gemini" && this.gemini) { - return this.embedChunksWithGeminiBatch(chunks, entry, source); + const { embeddings, missing } = this.collectCachedEmbeddings(chunks); + if (missing.length === 0) { + return embeddings; } - if (this.provider.id === "voyage" && this.voyage) { - return this.embedChunksWithVoyageBatch(chunks, entry, source); + + const missingChunks = missing.map((item) => item.chunk); + const batchResult = await this.runBatchWithFallback({ + provider: this.provider.id, + run: async () => + await batchEmbed({ + agentId: this.agentId, + chunks: missingChunks, + wait: this.batch.wait, + concurrency: this.batch.concurrency, + pollIntervalMs: this.batch.pollIntervalMs, + timeoutMs: this.batch.timeoutMs, + debug: this.buildBatchDebug(source, chunks), + }), + fallback: async () => await this.embedChunksInBatches(chunks), + }); + if (!batchResult) { + return this.embedChunksInBatches(chunks); } - return this.embedChunksInBatches(chunks); + const toCache: Array<{ hash: string; embedding: number[] }> = []; + for (let index = 0; index < missing.length; index += 1) { + const item = missing[index]; + const embedding = batchResult[index] ?? []; + if (!item) { + continue; + } + embeddings[item.index] = embedding; + toCache.push({ hash: item.chunk.hash, embedding }); + } + this.upsertEmbeddingCache(toCache); + return embeddings; } private collectCachedEmbeddings(chunks: MemoryChunk[]): { @@ -305,221 +305,6 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { return { embeddings, missing }; } - private buildBatchCustomId(params: { - source: MemorySource; - entry: MemoryFileEntry | SessionFileEntry; - chunk: MemoryChunk; - index: number; - }): string { - return hashText( - `${params.source}:${params.entry.path}:${params.chunk.startLine}:${params.chunk.endLine}:${params.chunk.hash}:${params.index}`, - ); - } - - private buildBatchRequests(params: { - missing: Array<{ index: number; chunk: MemoryChunk }>; - entry: MemoryFileEntry | SessionFileEntry; - source: MemorySource; - build: (chunk: MemoryChunk) => Omit; - }): { requests: T[]; mapping: Map } { - const requests: T[] = []; - const mapping = new Map(); - - for (const item of params.missing) { - const chunk = item.chunk; - const customId = this.buildBatchCustomId({ - source: params.source, - entry: params.entry, - chunk, - index: item.index, - }); - mapping.set(customId, { index: item.index, hash: chunk.hash }); - const built = params.build(chunk); - requests.push({ custom_id: customId, ...built } as T); - } - - return { requests, mapping }; - } - - private applyBatchEmbeddings(params: { - byCustomId: Map; - mapping: Map; - embeddings: number[][]; - }): void { - const toCache: Array<{ hash: string; embedding: number[] }> = []; - for (const [customId, embedding] of params.byCustomId.entries()) { - const mapped = params.mapping.get(customId); - if (!mapped) { - continue; - } - params.embeddings[mapped.index] = embedding; - toCache.push({ hash: mapped.hash, embedding }); - } - this.upsertEmbeddingCache(toCache); - } - - private buildEmbeddingBatchRunnerOptions(params: { - requests: TRequest[]; - chunks: MemoryChunk[]; - source: MemorySource; - }): { - agentId: string; - requests: TRequest[]; - wait: boolean; - concurrency: number; - pollIntervalMs: number; - timeoutMs: number; - debug: (message: string, data?: Record) => void; - } { - const { requests, chunks, source } = params; - return { - agentId: this.agentId, - requests, - wait: this.batch.wait, - concurrency: this.batch.concurrency, - pollIntervalMs: this.batch.pollIntervalMs, - timeoutMs: this.batch.timeoutMs, - debug: (message, data) => - log.debug( - message, - data ? { ...data, source, chunks: chunks.length } : { source, chunks: chunks.length }, - ), - }; - } - - private async embedChunksWithProviderBatch(params: { - chunks: MemoryChunk[]; - entry: MemoryFileEntry | SessionFileEntry; - source: MemorySource; - provider: "voyage" | "openai" | "gemini"; - enabled: boolean; - buildRequest: (chunk: MemoryChunk) => Omit; - runBatch: (runnerOptions: { - agentId: string; - requests: TRequest[]; - wait: boolean; - concurrency: number; - pollIntervalMs: number; - timeoutMs: number; - debug: (message: string, data?: Record) => void; - }) => Promise | number[][]>; - }): Promise { - if (!params.enabled) { - return this.embedChunksInBatches(params.chunks); - } - if (params.chunks.length === 0) { - return []; - } - const { embeddings, missing } = this.collectCachedEmbeddings(params.chunks); - if (missing.length === 0) { - return embeddings; - } - - const { requests, mapping } = this.buildBatchRequests({ - missing, - entry: params.entry, - source: params.source, - build: params.buildRequest, - }); - const runnerOptions = this.buildEmbeddingBatchRunnerOptions({ - requests, - chunks: params.chunks, - source: params.source, - }); - const batchResult = await this.runBatchWithFallback({ - provider: params.provider, - run: async () => await params.runBatch(runnerOptions), - fallback: async () => await this.embedChunksInBatches(params.chunks), - }); - if (Array.isArray(batchResult)) { - return batchResult; - } - this.applyBatchEmbeddings({ byCustomId: batchResult, mapping, embeddings }); - return embeddings; - } - - private async embedChunksWithVoyageBatch( - chunks: MemoryChunk[], - entry: MemoryFileEntry | SessionFileEntry, - source: MemorySource, - ): Promise { - const voyage = this.voyage; - return await this.embedChunksWithProviderBatch({ - chunks, - entry, - source, - provider: "voyage", - enabled: Boolean(voyage), - buildRequest: (chunk) => ({ - body: { input: chunk.text }, - }), - runBatch: async (runnerOptions) => - await runVoyageEmbeddingBatches({ - client: voyage!, - ...runnerOptions, - }), - }); - } - - private async embedChunksWithOpenAiBatch( - chunks: MemoryChunk[], - entry: MemoryFileEntry | SessionFileEntry, - source: MemorySource, - ): Promise { - const openAi = this.openAi; - return await this.embedChunksWithProviderBatch({ - chunks, - entry, - source, - provider: "openai", - enabled: Boolean(openAi), - buildRequest: (chunk) => ({ - method: "POST", - url: OPENAI_BATCH_ENDPOINT, - body: { - model: openAi?.model ?? this.provider?.model ?? "text-embedding-3-small", - input: chunk.text, - }, - }), - runBatch: async (runnerOptions) => - await runOpenAiEmbeddingBatches({ - openAi: openAi!, - ...runnerOptions, - }), - }); - } - - private async embedChunksWithGeminiBatch( - chunks: MemoryChunk[], - entry: MemoryFileEntry | SessionFileEntry, - source: MemorySource, - ): Promise { - const gemini = this.gemini; - if (chunks.some((chunk) => hasNonTextEmbeddingParts(chunk.embeddingInput))) { - return await this.embedChunksInBatches(chunks); - } - return await this.embedChunksWithProviderBatch({ - chunks, - entry, - source, - provider: "gemini", - enabled: Boolean(gemini), - buildRequest: (chunk) => ({ - request: buildGeminiEmbeddingRequest({ - input: chunk.embeddingInput ?? { text: chunk.text }, - taskType: "RETRIEVAL_DOCUMENT", - modelPath: this.gemini?.modelPath, - outputDimensionality: this.gemini?.outputDimensionality, - }), - }), - runBatch: async (runnerOptions) => - await runGeminiEmbeddingBatches({ - gemini: gemini!, - ...runnerOptions, - }), - }); - } - protected async embedBatchWithRetry(texts: string[]): Promise { if (texts.length === 0) { return []; diff --git a/extensions/memory-core/src/memory/manager-search.ts b/extensions/memory-core/src/memory/manager-search.ts index 18c4e5abb54..2b7ae663215 100644 --- a/extensions/memory-core/src/memory/manager-search.ts +++ b/extensions/memory-core/src/memory/manager-search.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import { cosineSimilarity, parseEmbedding, truncateUtf16Safe } from "../api.js"; +import { cosineSimilarity, parseEmbedding, truncateUtf16Safe } from "../engine-host-api.js"; const vectorToBlob = (embedding: number[]): Buffer => Buffer.from(new Float32Array(embedding).buffer); diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 729ffdd197f..79a679025de 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -5,15 +5,8 @@ import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import chokidar, { FSWatcher } from "chokidar"; import { - DEFAULT_GEMINI_EMBEDDING_MODEL, - DEFAULT_MISTRAL_EMBEDDING_MODEL, - DEFAULT_OLLAMA_EMBEDDING_MODEL, - DEFAULT_OPENAI_EMBEDDING_MODEL, - DEFAULT_VOYAGE_EMBEDDING_MODEL, buildCaseInsensitiveExtensionGlob, - buildFileEntry, classifyMemoryMultimodalPath, - createEmbeddingProvider, createSubsystemLogger, ensureDir, ensureMemoryIndexSchema, @@ -22,10 +15,8 @@ import { isFileMissingError, listMemoryFiles, listSessionFilesForAgent, - loadSqliteVecExtension, normalizeExtraMemoryPaths, onSessionTranscriptUpdate, - requireNodeSqlite, resolveAgentDir, resolveSessionTranscriptsDirForAgent, resolveUserPath, @@ -37,14 +28,22 @@ import { type OpenClawConfig, type ResolvedMemorySearchConfig, type SessionFileEntry, - type EmbeddingProvider, - type GeminiEmbeddingClient, - type MistralEmbeddingClient, - type OllamaEmbeddingClient, - type OpenAiEmbeddingClient, - type VoyageEmbeddingClient, buildSessionEntry, -} from "../api.js"; +} 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, +} from "./embeddings.js"; +import { buildFileEntry } from "./internal.js"; +import { loadSqliteVecExtension } from "./sqlite-vec.js"; +import { requireNodeSqlite } from "./sqlite.js"; type MemoryIndexMeta = { model: string; @@ -101,12 +100,8 @@ export abstract class MemoryManagerSyncOps { protected abstract readonly workspaceDir: string; protected abstract readonly settings: ResolvedMemorySearchConfig; protected provider: EmbeddingProvider | null = null; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; - protected openAi?: OpenAiEmbeddingClient; - protected gemini?: GeminiEmbeddingClient; - protected voyage?: VoyageEmbeddingClient; - protected mistral?: MistralEmbeddingClient; - protected ollama?: OllamaEmbeddingClient; + protected fallbackFrom?: EmbeddingProviderId; + protected providerRuntime?: EmbeddingProviderRuntime; protected abstract batch: { enabled: boolean; wait: boolean; @@ -1104,13 +1099,7 @@ export abstract class MemoryManagerSyncOps { timeoutMs: number; } { const batch = this.settings.remote?.batch; - const enabled = Boolean( - batch?.enabled && - this.provider && - ((this.openAi && this.provider.id === "openai") || - (this.gemini && this.provider.id === "gemini") || - (this.voyage && this.provider.id === "voyage")), - ); + const enabled = Boolean(batch?.enabled && this.provider && this.providerRuntime?.batchEmbed); return { enabled, wait: batch?.wait ?? true, @@ -1128,13 +1117,7 @@ export abstract class MemoryManagerSyncOps { if (this.fallbackFrom) { return false; } - const fallbackFrom = this.provider.id as - | "openai" - | "gemini" - | "local" - | "voyage" - | "mistral" - | "ollama"; + const fallbackFrom = this.provider.id as EmbeddingProviderId; const fallbackModel = fallback === "gemini" @@ -1163,11 +1146,7 @@ export abstract class MemoryManagerSyncOps { this.fallbackFrom = fallbackFrom; this.fallbackReason = reason; this.provider = fallbackResult.provider; - this.openAi = fallbackResult.openAi; - this.gemini = fallbackResult.gemini; - this.voyage = fallbackResult.voyage; - this.mistral = fallbackResult.mistral; - this.ollama = fallbackResult.ollama; + this.providerRuntime = fallbackResult.runtime; this.providerKey = this.computeProviderKey(); this.batch = this.resolveBatchConfig(); log.warn(`memory embeddings: switched to fallback provider (${fallback})`, { reason }); diff --git a/src/memory/manager.async-search.test.ts b/extensions/memory-core/src/memory/manager.async-search.test.ts similarity index 94% rename from src/memory/manager.async-search.test.ts rename to extensions/memory-core/src/memory/manager.async-search.test.ts index ed85d862b17..27c8f8d6fe4 100644 --- a/src/memory/manager.async-search.test.ts +++ b/extensions/memory-core/src/memory/manager.async-search.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; -import { closeAllMemorySearchManagers } from "../../extensions/memory-core/src/memory/index.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import type { MemoryIndexManager } from "./index.js"; +import { closeAllMemorySearchManagers } from "./index.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; import { createMemoryManagerOrThrow } from "./test-manager.js"; diff --git a/src/memory/manager.atomic-reindex.test.ts b/extensions/memory-core/src/memory/manager.atomic-reindex.test.ts similarity index 90% rename from src/memory/manager.atomic-reindex.test.ts rename to extensions/memory-core/src/memory/manager.atomic-reindex.test.ts index e4d37ce344c..fe26316900d 100644 --- a/src/memory/manager.atomic-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.atomic-reindex.test.ts @@ -2,14 +2,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import type { MemoryIndexManager } from "./index.js"; let shouldFail = false; type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js"); type TestManagerHelpersModule = typeof import("./test-manager-helpers.js"); -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); +type MemoryIndexModule = typeof import("./index.js"); describe("memory manager atomic reindex", () => { let fixtureRoot = ""; @@ -32,8 +32,7 @@ describe("memory manager atomic reindex", () => { embedBatch = embeddingMocks.getEmbedBatchMock(); resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks; ({ getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js")); - ({ closeAllMemorySearchManagers } = - await import("../../extensions/memory-core/src/memory/index.js")); + ({ closeAllMemorySearchManagers } = await import("./index.js")); vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0"); resetEmbeddingMocks(); shouldFail = false; diff --git a/src/memory/manager.batch.test.ts b/extensions/memory-core/src/memory/manager.batch.test.ts similarity index 96% rename from src/memory/manager.batch.test.ts rename to extensions/memory-core/src/memory/manager.batch.test.ts index 1d324c78541..761d4cea1c7 100644 --- a/src/memory/manager.batch.test.ts +++ b/extensions/memory-core/src/memory/manager.batch.test.ts @@ -2,14 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { useFastShortTimeouts } from "../../test/helpers/fast-short-timeouts.js"; -import type { OpenClawConfig } from "../config/config.js"; +import { useFastShortTimeouts } from "../../../../test/helpers/fast-short-timeouts.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; -type MemoryIndexManager = - import("../../extensions/memory-core/src/memory/index.js").MemoryIndexManager; -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); +type MemoryIndexManager = import("./index.js").MemoryIndexManager; +type MemoryIndexModule = typeof import("./index.js"); const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]); const embedQuery = vi.fn(async () => [0.5, 0.5, 0.5]); @@ -122,7 +121,7 @@ describe("memory indexing with OpenAI batches", () => { }), })); await import("./test-runtime-mocks.js"); - ({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js")); + ({ getMemorySearchManager } = await import("./index.js")); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-")); workspaceDir = path.join(fixtureRoot, "workspace"); diff --git a/src/memory/manager.embedding-batches.test.ts b/extensions/memory-core/src/memory/manager.embedding-batches.test.ts similarity index 98% rename from src/memory/manager.embedding-batches.test.ts rename to extensions/memory-core/src/memory/manager.embedding-batches.test.ts index d7b1071deed..159cd636327 100644 --- a/src/memory/manager.embedding-batches.test.ts +++ b/extensions/memory-core/src/memory/manager.embedding-batches.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { useFastShortTimeouts } from "../../test/helpers/fast-short-timeouts.js"; +import { useFastShortTimeouts } from "../../../../test/helpers/fast-short-timeouts.js"; import { installEmbeddingManagerFixture } from "./embedding-manager.test-harness.js"; const fx = installEmbeddingManagerFixture({ diff --git a/src/memory/manager.get-concurrency.test.ts b/extensions/memory-core/src/memory/manager.get-concurrency.test.ts similarity index 88% rename from src/memory/manager.get-concurrency.test.ts rename to extensions/memory-core/src/memory/manager.get-concurrency.test.ts index 21e5bfb9d5c..af60bf7a8ef 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/extensions/memory-core/src/memory/manager.get-concurrency.test.ts @@ -3,12 +3,12 @@ import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; import "./test-runtime-mocks.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { MemoryIndexManager } from "./index.js"; -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); -type ManagerModule = typeof import("../../extensions/memory-core/src/memory/manager.js"); +type MemoryIndexModule = typeof import("./index.js"); +type ManagerModule = typeof import("./manager.js"); const hoisted = vi.hoisted(() => ({ providerCreateCalls: 0, @@ -43,10 +43,9 @@ describe("memory manager cache hydration", () => { let workspaceDir = ""; beforeAll(async () => { - ({ getMemorySearchManager, closeAllMemorySearchManagers } = - await import("../../extensions/memory-core/src/memory/index.js")); + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); ({ closeAllMemoryIndexManagers, MemoryIndexManager: RawMemoryIndexManager } = - await import("../../extensions/memory-core/src/memory/manager.js")); + await import("./manager.js")); }); beforeEach(async () => { diff --git a/src/memory/manager.mistral-provider.test.ts b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts similarity index 74% rename from src/memory/manager.mistral-provider.test.ts rename to extensions/memory-core/src/memory/manager.mistral-provider.test.ts index b6c6490edbf..5d4e54a480f 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts @@ -2,31 +2,32 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import type { EmbeddingProvider, + EmbeddingProviderRuntime, EmbeddingProviderResult, - MistralEmbeddingClient, - OllamaEmbeddingClient, - OpenAiEmbeddingClient, } from "./embeddings.js"; +import type { MemoryIndexManager } from "./index.js"; +type MemoryIndexModule = typeof import("./index.js"); const { createEmbeddingProviderMock } = vi.hoisted(() => ({ createEmbeddingProviderMock: vi.fn(), })); -vi.mock("./embeddings.js", () => ({ - createEmbeddingProvider: createEmbeddingProviderMock, -})); +vi.mock("./embeddings.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createEmbeddingProvider: createEmbeddingProviderMock, + }; +}); vi.mock("./sqlite-vec.js", () => ({ loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), })); -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); - let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; @@ -78,8 +79,7 @@ describe("memory manager mistral provider wiring", () => { beforeEach(async () => { vi.resetModules(); - ({ getMemorySearchManager, closeAllMemorySearchManagers } = - await import("../../extensions/memory-core/src/memory/index.js")); + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); vi.clearAllMocks(); createEmbeddingProviderMock.mockReset(); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-")); @@ -102,15 +102,14 @@ describe("memory manager mistral provider wiring", () => { }); it("stores mistral client when mistral provider is selected", async () => { - const mistralClient: MistralEmbeddingClient = { - baseUrl: "https://api.mistral.ai/v1", - headers: { authorization: "Bearer test-key" }, - model: "mistral-embed", + const mistralRuntime: EmbeddingProviderRuntime = { + id: "mistral", + cacheKeyData: { provider: "mistral", model: "mistral-embed" }, }; const providerResult: EmbeddingProviderResult = { requestedProvider: "mistral", provider: createProvider("mistral"), - mistral: mistralClient, + runtime: mistralRuntime, }; createEmbeddingProviderMock.mockResolvedValueOnce(providerResult); @@ -124,32 +123,30 @@ describe("memory manager mistral provider wiring", () => { const internal = manager as unknown as { ensureProviderInitialized: () => Promise; - mistral?: MistralEmbeddingClient; + providerRuntime?: EmbeddingProviderRuntime; }; await internal.ensureProviderInitialized(); - expect(internal.mistral).toBe(mistralClient); + expect(internal.providerRuntime).toBe(mistralRuntime); }); it("stores mistral client after fallback activation", async () => { - const openAiClient: OpenAiEmbeddingClient = { - baseUrl: "https://api.openai.com/v1", - headers: { authorization: "Bearer openai-key" }, - model: "text-embedding-3-small", + const openAiRuntime: EmbeddingProviderRuntime = { + id: "openai", + cacheKeyData: { provider: "openai", model: "text-embedding-3-small" }, }; - const mistralClient: MistralEmbeddingClient = { - baseUrl: "https://api.mistral.ai/v1", - headers: { authorization: "Bearer mistral-key" }, - model: "mistral-embed", + const mistralRuntime: EmbeddingProviderRuntime = { + id: "mistral", + cacheKeyData: { provider: "mistral", model: "mistral-embed" }, }; createEmbeddingProviderMock.mockResolvedValueOnce({ requestedProvider: "openai", provider: createProvider("openai"), - openAi: openAiClient, + runtime: openAiRuntime, } as EmbeddingProviderResult); createEmbeddingProviderMock.mockResolvedValueOnce({ requestedProvider: "mistral", provider: createProvider("mistral"), - mistral: mistralClient, + runtime: mistralRuntime, } as EmbeddingProviderResult); const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "mistral" }); @@ -162,38 +159,34 @@ describe("memory manager mistral provider wiring", () => { const internal = manager as unknown as { ensureProviderInitialized: () => Promise; activateFallbackProvider: (reason: string) => Promise; - openAi?: OpenAiEmbeddingClient; - mistral?: MistralEmbeddingClient; + providerRuntime?: EmbeddingProviderRuntime; }; await internal.ensureProviderInitialized(); + expect(internal.providerRuntime?.id).toBe("openai"); const activated = await internal.activateFallbackProvider("forced test"); expect(activated).toBe(true); - expect(internal.openAi).toBeUndefined(); - expect(internal.mistral).toBe(mistralClient); + expect(internal.providerRuntime).toBe(mistralRuntime); }); it("uses default ollama model when activating ollama fallback", async () => { - const openAiClient: OpenAiEmbeddingClient = { - baseUrl: "https://api.openai.com/v1", - headers: { authorization: "Bearer openai-key" }, - model: "text-embedding-3-small", + const openAiRuntime: EmbeddingProviderRuntime = { + id: "openai", + cacheKeyData: { provider: "openai", model: "text-embedding-3-small" }, }; - const ollamaClient: OllamaEmbeddingClient = { - baseUrl: "http://127.0.0.1:11434", - headers: {}, - model: DEFAULT_OLLAMA_EMBEDDING_MODEL, - embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]), + const ollamaRuntime: EmbeddingProviderRuntime = { + id: "ollama", + cacheKeyData: { provider: "ollama", model: DEFAULT_OLLAMA_EMBEDDING_MODEL }, }; createEmbeddingProviderMock.mockResolvedValueOnce({ requestedProvider: "openai", provider: createProvider("openai"), - openAi: openAiClient, + runtime: openAiRuntime, } as EmbeddingProviderResult); createEmbeddingProviderMock.mockResolvedValueOnce({ requestedProvider: "ollama", provider: createProvider("ollama"), - ollama: ollamaClient, + runtime: ollamaRuntime, } as EmbeddingProviderResult); const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "ollama" }); @@ -206,15 +199,14 @@ describe("memory manager mistral provider wiring", () => { const internal = manager as unknown as { ensureProviderInitialized: () => Promise; activateFallbackProvider: (reason: string) => Promise; - openAi?: OpenAiEmbeddingClient; - ollama?: OllamaEmbeddingClient; + providerRuntime?: EmbeddingProviderRuntime; }; await internal.ensureProviderInitialized(); + expect(internal.providerRuntime?.id).toBe("openai"); const activated = await internal.activateFallbackProvider("forced ollama fallback"); expect(activated).toBe(true); - expect(internal.openAi).toBeUndefined(); - expect(internal.ollama).toBe(ollamaClient); + expect(internal.providerRuntime).toBe(ollamaRuntime); const fallbackCall = createEmbeddingProviderMock.mock.calls[1]?.[0] as | { provider?: string; model?: string } diff --git a/src/memory/manager.read-file.test.ts b/extensions/memory-core/src/memory/manager.read-file.test.ts similarity index 96% rename from src/memory/manager.read-file.test.ts rename to extensions/memory-core/src/memory/manager.read-file.test.ts index 877bfbc4705..2be8910a79d 100644 --- a/src/memory/manager.read-file.test.ts +++ b/extensions/memory-core/src/memory/manager.read-file.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; import { resetEmbeddingMocks } from "./embedding.test-mocks.js"; +import type { MemoryIndexManager } from "./index.js"; import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js"; function createMemorySearchCfg(options: { diff --git a/src/memory/manager.readonly-recovery.test.ts b/extensions/memory-core/src/memory/manager.readonly-recovery.test.ts similarity index 97% rename from src/memory/manager.readonly-recovery.test.ts rename to extensions/memory-core/src/memory/manager.readonly-recovery.test.ts index f4fd9dfbfaa..ca1a150f98f 100644 --- a/src/memory/manager.readonly-recovery.test.ts +++ b/extensions/memory-core/src/memory/manager.readonly-recovery.test.ts @@ -3,9 +3,9 @@ import os from "node:os"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MemoryIndexManager } from "../../extensions/memory-core/src/memory/manager.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; import { resetEmbeddingMocks } from "./embedding.test-mocks.js"; +import { MemoryIndexManager } from "./manager.js"; import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js"; type ReadonlyRecoveryHarness = { diff --git a/src/memory/manager.sync-errors-do-not-crash.test.ts b/extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts similarity index 90% rename from src/memory/manager.sync-errors-do-not-crash.test.ts rename to extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts index 129c36b1702..f649eab5afd 100644 --- a/src/memory/manager.sync-errors-do-not-crash.test.ts +++ b/extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runDetachedMemorySync } from "../../extensions/memory-core/src/memory/manager-sync-ops.js"; +import { runDetachedMemorySync } from "./manager-sync-ops.js"; describe("memory manager sync failures", () => { beforeEach(() => { diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index b1abf33054a..a5a82217482 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -1,15 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import { type FSWatcher } from "chokidar"; import { - createEmbeddingProvider, - type EmbeddingProvider, - type EmbeddingProviderRequest, - type EmbeddingProviderResult, - type GeminiEmbeddingClient, - type MistralEmbeddingClient, - type OllamaEmbeddingClient, - type OpenAiEmbeddingClient, - type VoyageEmbeddingClient, extractKeywords, readMemoryFile, resolveAgentDir, @@ -25,7 +16,15 @@ import { type OpenClawConfig, type ResolvedMemorySearchConfig, createSubsystemLogger, -} from "../api.js"; +} from "../engine-host-api.js"; +import { + createEmbeddingProvider, + type EmbeddingProvider, + type EmbeddingProviderId, + type EmbeddingProviderRequest, + type EmbeddingProviderResult, + type EmbeddingProviderRuntime, +} from "./embeddings.js"; import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js"; import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js"; import { searchKeyword, searchVector } from "./manager-search.js"; @@ -83,14 +82,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem private readonly requestedProvider: EmbeddingProviderRequest; private providerInitPromise: Promise | null = null; private providerInitialized = false; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; + protected fallbackFrom?: EmbeddingProviderId; protected fallbackReason?: string; private providerUnavailableReason?: string; - protected openAi?: OpenAiEmbeddingClient; - protected gemini?: GeminiEmbeddingClient; - protected voyage?: VoyageEmbeddingClient; - protected mistral?: MistralEmbeddingClient; - protected ollama?: OllamaEmbeddingClient; + protected providerRuntime?: EmbeddingProviderRuntime; protected batch: { enabled: boolean; wait: boolean; @@ -270,11 +265,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.fallbackFrom = providerResult.fallbackFrom; this.fallbackReason = providerResult.fallbackReason; this.providerUnavailableReason = providerResult.providerUnavailableReason; - this.openAi = providerResult.openAi; - this.gemini = providerResult.gemini; - this.voyage = providerResult.voyage; - this.mistral = providerResult.mistral; - this.ollama = providerResult.ollama; + this.providerRuntime = providerResult.runtime; this.providerInitialized = true; } diff --git a/src/memory/manager.vector-dedupe.test.ts b/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts similarity index 92% rename from src/memory/manager.vector-dedupe.test.ts rename to extensions/memory-core/src/memory/manager.vector-dedupe.test.ts index ae2c18b364a..d83780c5700 100644 --- a/src/memory/manager.vector-dedupe.test.ts +++ b/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import type { MemoryIndexManager } from "./index.js"; vi.mock("./embeddings.js", () => { return { @@ -21,7 +21,7 @@ vi.mock("./embeddings.js", () => { type MemoryInternalModule = typeof import("./internal.js"); type TestManagerModule = typeof import("./test-manager.js"); -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); +type MemoryIndexModule = typeof import("./index.js"); let buildFileEntry: MemoryInternalModule["buildFileEntry"]; let createMemoryManagerOrThrow: TestManagerModule["createMemoryManagerOrThrow"]; @@ -57,8 +57,7 @@ describe("memory vector dedupe", () => { vi.resetModules(); ({ buildFileEntry } = await import("./internal.js")); ({ createMemoryManagerOrThrow } = await import("./test-manager.js")); - ({ closeAllMemorySearchManagers } = - await import("../../extensions/memory-core/src/memory/index.js")); + ({ closeAllMemorySearchManagers } = await import("./index.js")); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-")); indexPath = path.join(workspaceDir, "index.sqlite"); await seedMemoryWorkspace(workspaceDir); diff --git a/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts similarity index 91% rename from src/memory/manager.watcher-config.test.ts rename to extensions/memory-core/src/memory/manager.watcher-config.test.ts index f8660aa6a34..4598a898df5 100644 --- a/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { MemorySearchConfig } from "../config/types.tools.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import type { MemorySearchConfig } from "../engine-host-api.js"; +import type { MemoryIndexManager } from "./index.js"; const { watchMock } = vi.hoisted(() => ({ watchMock: vi.fn(() => ({ @@ -34,7 +34,7 @@ vi.mock("./embeddings.js", () => ({ }), })); -type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js"); +type MemoryIndexModule = typeof import("./index.js"); let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; @@ -46,8 +46,7 @@ describe("memory watcher config", () => { beforeEach(async () => { vi.resetModules(); - ({ getMemorySearchManager, closeAllMemorySearchManagers } = - await import("../../extensions/memory-core/src/memory/index.js")); + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); vi.clearAllMocks(); }); diff --git a/src/memory/mmr.test.ts b/extensions/memory-core/src/memory/mmr.test.ts similarity index 99% rename from src/memory/mmr.test.ts rename to extensions/memory-core/src/memory/mmr.test.ts index b6b972a0242..621d1e509c8 100644 --- a/src/memory/mmr.test.ts +++ b/extensions/memory-core/src/memory/mmr.test.ts @@ -8,7 +8,7 @@ import { applyMMRToHybridResults, DEFAULT_MMR_CONFIG, type MMRItem, -} from "../../extensions/memory-core/src/memory/mmr.js"; +} from "./mmr.js"; describe("tokenize", () => { it("normalizes, filters, and deduplicates token sets", () => { diff --git a/extensions/memory-core/src/memory/provider-adapters.ts b/extensions/memory-core/src/memory/provider-adapters.ts new file mode 100644 index 00000000000..9be26207149 --- /dev/null +++ b/extensions/memory-core/src/memory/provider-adapters.ts @@ -0,0 +1,359 @@ +import fsSync from "node:fs"; +import { + DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LOCAL_MODEL, + DEFAULT_MISTRAL_EMBEDDING_MODEL, + DEFAULT_OLLAMA_EMBEDDING_MODEL, + DEFAULT_OPENAI_EMBEDDING_MODEL, + DEFAULT_VOYAGE_EMBEDDING_MODEL, + OPENAI_BATCH_ENDPOINT, + buildGeminiEmbeddingRequest, + createGeminiEmbeddingProvider, + createLocalEmbeddingProvider, + createMistralEmbeddingProvider, + createOllamaEmbeddingProvider, + createOpenAiEmbeddingProvider, + createVoyageEmbeddingProvider, + hasNonTextEmbeddingParts, + listMemoryEmbeddingProviders, + resolveUserPath, + runGeminiEmbeddingBatches, + runOpenAiEmbeddingBatches, + runVoyageEmbeddingBatches, + type MemoryEmbeddingProviderAdapter, +} from "../engine-host-api.js"; + +function formatErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isMissingApiKeyError(err: unknown): boolean { + return formatErrorMessage(err).includes("No API key found for provider"); +} + +function sanitizeHeaders( + headers: Record, + excludedHeaderNames: string[], +): Array<[string, string]> { + const excluded = new Set(excludedHeaderNames.map((name) => name.toLowerCase())); + return Object.entries(headers) + .filter(([key]) => !excluded.has(key.toLowerCase())) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, value]); +} + +function mapBatchEmbeddingsByIndex(byCustomId: Map, count: number): number[][] { + const embeddings: number[][] = []; + for (let index = 0; index < count; index += 1) { + embeddings.push(byCustomId.get(String(index)) ?? []); + } + return embeddings; +} + +function isNodeLlamaCppMissing(err: unknown): boolean { + if (!(err instanceof Error)) { + return false; + } + const code = (err as Error & { code?: unknown }).code; + return code === "ERR_MODULE_NOT_FOUND" && err.message.includes("node-llama-cpp"); +} + +function formatLocalSetupError(err: unknown): string { + const detail = formatErrorMessage(err); + const missing = isNodeLlamaCppMissing(err); + return [ + "Local embeddings unavailable.", + missing + ? "Reason: optional dependency node-llama-cpp is missing (or failed to install)." + : detail + ? `Reason: ${detail}` + : undefined, + missing && detail ? `Detail: ${detail}` : null, + "To enable local embeddings:", + "1) Use Node 24 (recommended for installs/updates; Node 22 LTS, currently 22.14+, remains supported)", + missing + ? "2) Reinstall OpenClaw (this should install node-llama-cpp): npm i -g openclaw@latest" + : null, + "3) If you use pnpm: pnpm approve-builds (select node-llama-cpp), then pnpm rebuild node-llama-cpp", + ...["openai", "gemini", "voyage", "mistral"].map( + (provider) => `Or set agents.defaults.memorySearch.provider = "${provider}" (remote).`, + ), + ] + .filter(Boolean) + .join("\n"); +} + +function canAutoSelectLocal(modelPath?: string): boolean { + const trimmed = modelPath?.trim(); + if (!trimmed) { + return false; + } + if (/^(hf:|https?:)/i.test(trimmed)) { + return false; + } + const resolved = resolveUserPath(trimmed); + try { + return fsSync.statSync(resolved).isFile(); + } catch { + return false; + } +} + +function supportsGeminiMultimodalEmbeddings(model: string): boolean { + const normalized = model + .trim() + .replace(/^models\//, "") + .replace(/^(gemini|google)\//, ""); + return normalized === "gemini-embedding-2-preview"; +} + +const openAiAdapter: MemoryEmbeddingProviderAdapter = { + id: "openai", + defaultModel: DEFAULT_OPENAI_EMBEDDING_MODEL, + transport: "remote", + autoSelectPriority: 20, + allowExplicitWhenConfiguredAuto: true, + shouldContinueAutoSelection: isMissingApiKeyError, + create: async (options) => { + const { provider, client } = await createOpenAiEmbeddingProvider({ + ...options, + provider: "openai", + fallback: "none", + }); + return { + provider, + runtime: { + id: "openai", + cacheKeyData: { + provider: "openai", + baseUrl: client.baseUrl, + model: client.model, + headers: sanitizeHeaders(client.headers, ["authorization"]), + }, + batchEmbed: async (batch) => { + const byCustomId = await runOpenAiEmbeddingBatches({ + openAi: client, + agentId: batch.agentId, + requests: batch.chunks.map((chunk, index) => ({ + custom_id: String(index), + method: "POST", + url: OPENAI_BATCH_ENDPOINT, + body: { + model: client.model, + input: chunk.text, + }, + })), + wait: batch.wait, + concurrency: batch.concurrency, + pollIntervalMs: batch.pollIntervalMs, + timeoutMs: batch.timeoutMs, + debug: batch.debug, + }); + return mapBatchEmbeddingsByIndex(byCustomId, batch.chunks.length); + }, + }, + }; + }, +}; + +const geminiAdapter: MemoryEmbeddingProviderAdapter = { + id: "gemini", + defaultModel: DEFAULT_GEMINI_EMBEDDING_MODEL, + transport: "remote", + autoSelectPriority: 30, + allowExplicitWhenConfiguredAuto: true, + supportsMultimodalEmbeddings: ({ model }) => supportsGeminiMultimodalEmbeddings(model), + shouldContinueAutoSelection: isMissingApiKeyError, + create: async (options) => { + const { provider, client } = await createGeminiEmbeddingProvider({ + ...options, + provider: "gemini", + fallback: "none", + }); + return { + provider, + runtime: { + id: "gemini", + cacheKeyData: { + provider: "gemini", + baseUrl: client.baseUrl, + model: client.model, + outputDimensionality: client.outputDimensionality, + headers: sanitizeHeaders(client.headers, ["authorization", "x-goog-api-key"]), + }, + batchEmbed: async (batch) => { + if (batch.chunks.some((chunk) => hasNonTextEmbeddingParts(chunk.embeddingInput))) { + return null; + } + const byCustomId = await runGeminiEmbeddingBatches({ + gemini: client, + agentId: batch.agentId, + requests: batch.chunks.map((chunk, index) => ({ + custom_id: String(index), + request: buildGeminiEmbeddingRequest({ + input: chunk.embeddingInput ?? { text: chunk.text }, + taskType: "RETRIEVAL_DOCUMENT", + modelPath: client.modelPath, + outputDimensionality: client.outputDimensionality, + }), + })), + wait: batch.wait, + concurrency: batch.concurrency, + pollIntervalMs: batch.pollIntervalMs, + timeoutMs: batch.timeoutMs, + debug: batch.debug, + }); + return mapBatchEmbeddingsByIndex(byCustomId, batch.chunks.length); + }, + }, + }; + }, +}; + +const voyageAdapter: MemoryEmbeddingProviderAdapter = { + id: "voyage", + defaultModel: DEFAULT_VOYAGE_EMBEDDING_MODEL, + transport: "remote", + autoSelectPriority: 40, + allowExplicitWhenConfiguredAuto: true, + shouldContinueAutoSelection: isMissingApiKeyError, + create: async (options) => { + const { provider, client } = await createVoyageEmbeddingProvider({ + ...options, + provider: "voyage", + fallback: "none", + }); + return { + provider, + runtime: { + id: "voyage", + batchEmbed: async (batch) => { + const byCustomId = await runVoyageEmbeddingBatches({ + client, + agentId: batch.agentId, + requests: batch.chunks.map((chunk, index) => ({ + custom_id: String(index), + body: { + input: chunk.text, + }, + })), + wait: batch.wait, + concurrency: batch.concurrency, + pollIntervalMs: batch.pollIntervalMs, + timeoutMs: batch.timeoutMs, + debug: batch.debug, + }); + return mapBatchEmbeddingsByIndex(byCustomId, batch.chunks.length); + }, + }, + }; + }, +}; + +const mistralAdapter: MemoryEmbeddingProviderAdapter = { + id: "mistral", + defaultModel: DEFAULT_MISTRAL_EMBEDDING_MODEL, + transport: "remote", + autoSelectPriority: 50, + allowExplicitWhenConfiguredAuto: true, + shouldContinueAutoSelection: isMissingApiKeyError, + create: async (options) => { + const { provider, client } = await createMistralEmbeddingProvider({ + ...options, + provider: "mistral", + fallback: "none", + }); + return { + provider, + runtime: { + id: "mistral", + cacheKeyData: { + provider: "mistral", + model: client.model, + }, + }, + }; + }, +}; + +const ollamaAdapter: MemoryEmbeddingProviderAdapter = { + id: "ollama", + defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL, + transport: "remote", + create: async (options) => { + const { provider, client } = await createOllamaEmbeddingProvider({ + ...options, + provider: "ollama", + fallback: "none", + }); + return { + provider, + runtime: { + id: "ollama", + cacheKeyData: { + provider: "ollama", + model: client.model, + }, + }, + }; + }, +}; + +const localAdapter: MemoryEmbeddingProviderAdapter = { + id: "local", + defaultModel: DEFAULT_LOCAL_MODEL, + transport: "local", + autoSelectPriority: 10, + formatSetupError: formatLocalSetupError, + shouldContinueAutoSelection: () => true, + create: async (options) => { + const provider = await createLocalEmbeddingProvider({ + ...options, + provider: "local", + fallback: "none", + }); + return { + provider, + runtime: { + id: "local", + cacheKeyData: { + provider: "local", + model: provider.model, + }, + }, + }; + }, +}; + +export const builtinMemoryEmbeddingProviderAdapters = [ + localAdapter, + openAiAdapter, + geminiAdapter, + voyageAdapter, + mistralAdapter, + ollamaAdapter, +] as const; + +export function registerBuiltInMemoryEmbeddingProviders(register: { + registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void; +}): void { + const existingIds = new Set(listMemoryEmbeddingProviders().map((adapter) => adapter.id)); + for (const adapter of builtinMemoryEmbeddingProviderAdapters) { + if (existingIds.has(adapter.id)) { + continue; + } + register.registerMemoryEmbeddingProvider(adapter); + } +} + +export { + DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LOCAL_MODEL, + DEFAULT_MISTRAL_EMBEDDING_MODEL, + DEFAULT_OLLAMA_EMBEDDING_MODEL, + DEFAULT_OPENAI_EMBEDDING_MODEL, + DEFAULT_VOYAGE_EMBEDDING_MODEL, + canAutoSelectLocal, + formatLocalSetupError, + isMissingApiKeyError, +}; diff --git a/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts similarity index 99% rename from src/memory/qmd-manager.test.ts rename to extensions/memory-core/src/memory/qmd-manager.test.ts index 3d2ad04262b..3b5d57e257d 100644 --- a/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -67,17 +67,23 @@ function isMcporterCommand(cmd: unknown): boolean { return /(^|[\\/])mcporter(?:\.cmd)?$/i.test(cmd); } -vi.mock("../logging/subsystem.js", () => ({ - createSubsystemLogger: () => { - const logger = { - warn: logWarnMock, - debug: logDebugMock, - info: logInfoMock, - child: () => logger, - }; - return logger; - }, -})); +vi.mock("openclaw/plugin-sdk/memory-core-host-engine", async () => { + const actual = await vi.importActual< + typeof import("openclaw/plugin-sdk/memory-core-host-engine") + >("openclaw/plugin-sdk/memory-core-host-engine"); + return { + ...actual, + createSubsystemLogger: () => { + const logger = { + warn: logWarnMock, + debug: logDebugMock, + info: logInfoMock, + child: () => logger, + }; + return logger; + }, + }; +}); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -88,10 +94,12 @@ vi.mock("node:child_process", async (importOriginal) => { }); import { spawn as mockedSpawn } from "node:child_process"; -import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveMemoryBackendConfig } from "./backend-config.js"; -import { requireNodeSqlite } from "./sqlite.js"; +import { + requireNodeSqlite, + resolveMemoryBackendConfig, + type OpenClawConfig, +} from "../engine-host-api.js"; +import { QmdMemoryManager } from "./qmd-manager.js"; const spawnMock = mockedSpawn as unknown as Mock; const originalPath = process.env.PATH; diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 7b52a71b343..13a99b24fb5 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -12,7 +12,6 @@ import { isQmdScopeAllowed, listSessionFilesForAgent, parseQmdQueryJson, - requireNodeSqlite, resolveAgentWorkspaceDir, resolveCliSpawnInvocation, resolveGlobalSingleton, @@ -32,7 +31,8 @@ import { type ResolvedQmdMcporterConfig, type SessionFileEntry, writeFileWithinRoot, -} from "../api.js"; +} from "../engine-host-api.js"; +import { requireNodeSqlite } from "./sqlite.js"; type SqliteDatabase = import("node:sqlite").DatabaseSync; diff --git a/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts similarity index 97% rename from src/memory/search-manager.test.ts rename to extensions/memory-core/src/memory/search-manager.test.ts index 9c6d435bbf8..eab8fc4ceeb 100644 --- a/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; function createManagerStatus(params: { backend: "qmd" | "builtin"; @@ -96,7 +96,7 @@ const fallbackSearch = fallbackManager.search; const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager)); const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("../../extensions/memory-core/src/memory/qmd-manager.js", () => ({ +vi.mock("./qmd-manager.js", () => ({ QmdMemoryManager: { create: vi.fn(async () => mockPrimary), }, @@ -109,11 +109,8 @@ vi.mock("../../extensions/memory-core/src/memory/manager-runtime.js", () => ({ closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers, })); -import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js"; -import { - closeAllMemorySearchManagers, - getMemorySearchManager, -} from "../../extensions/memory-core/src/memory/search-manager.js"; +import { QmdMemoryManager } from "./qmd-manager.js"; +import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js"; // eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function const createQmdManagerMock = vi.mocked(QmdMemoryManager.create); diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index a6892918294..f68d67dc258 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -7,7 +7,7 @@ import { type MemorySyncProgressUpdate, type OpenClawConfig, type ResolvedQmdConfig, -} from "../api.js"; +} from "../engine-host-api.js"; const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache"); type MemorySearchManagerCacheStore = { diff --git a/extensions/memory-core/src/memory/sqlite-vec.ts b/extensions/memory-core/src/memory/sqlite-vec.ts new file mode 100644 index 00000000000..c3a195b5473 --- /dev/null +++ b/extensions/memory-core/src/memory/sqlite-vec.ts @@ -0,0 +1 @@ +export { loadSqliteVecExtension } from "../engine-host-api.js"; diff --git a/extensions/memory-core/src/memory/sqlite.ts b/extensions/memory-core/src/memory/sqlite.ts new file mode 100644 index 00000000000..5e59ffd8aaa --- /dev/null +++ b/extensions/memory-core/src/memory/sqlite.ts @@ -0,0 +1 @@ +export { requireNodeSqlite } from "../engine-host-api.js"; diff --git a/src/memory/temporal-decay.test.ts b/extensions/memory-core/src/memory/temporal-decay.test.ts similarity index 97% rename from src/memory/temporal-decay.test.ts rename to extensions/memory-core/src/memory/temporal-decay.test.ts index 9acd8142c2e..edb4df553d6 100644 --- a/src/memory/temporal-decay.test.ts +++ b/extensions/memory-core/src/memory/temporal-decay.test.ts @@ -2,12 +2,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { mergeHybridResults } from "../../extensions/memory-core/src/memory/hybrid.js"; +import { mergeHybridResults } from "./hybrid.js"; import { applyTemporalDecayToHybridResults, applyTemporalDecayToScore, calculateTemporalDecayMultiplier, -} from "../../extensions/memory-core/src/memory/temporal-decay.js"; +} from "./temporal-decay.js"; const DAY_MS = 24 * 60 * 60 * 1000; const NOW_MS = Date.UTC(2026, 1, 10, 0, 0, 0); diff --git a/extensions/memory-core/src/memory/test-embeddings-mock.ts b/extensions/memory-core/src/memory/test-embeddings-mock.ts new file mode 100644 index 00000000000..cfa472d8725 --- /dev/null +++ b/extensions/memory-core/src/memory/test-embeddings-mock.ts @@ -0,0 +1,64 @@ +import { + OPENAI_BATCH_ENDPOINT, + runOpenAiEmbeddingBatches, + type MemoryChunk, +} from "../engine-host-api.js"; + +export function createOpenAIEmbeddingProviderMock(params: { + embedQuery: (input: string) => Promise; + embedBatch: (input: string[]) => Promise; +}) { + const openAiClient = { + baseUrl: "https://api.openai.com/v1", + headers: { Authorization: "Bearer test", "Content-Type": "application/json" }, + model: "text-embedding-3-small", + }; + return { + requestedProvider: "openai", + provider: { + id: "openai", + model: "text-embedding-3-small", + embedQuery: params.embedQuery, + embedBatch: params.embedBatch, + }, + runtime: { + id: "openai", + cacheKeyData: { + provider: "openai", + baseUrl: openAiClient.baseUrl, + model: openAiClient.model, + }, + batchEmbed: async (options: { + agentId: string; + chunks: MemoryChunk[]; + wait: boolean; + concurrency: number; + pollIntervalMs: number; + timeoutMs: number; + debug: (message: string, data?: Record) => void; + }) => { + const byCustomId = await runOpenAiEmbeddingBatches({ + openAi: openAiClient, + agentId: options.agentId, + requests: options.chunks.map((chunk: MemoryChunk, index: number) => ({ + custom_id: String(index), + method: "POST", + url: OPENAI_BATCH_ENDPOINT, + body: { + model: openAiClient.model, + input: chunk.text, + }, + })), + wait: options.wait, + concurrency: options.concurrency, + pollIntervalMs: options.pollIntervalMs, + timeoutMs: options.timeoutMs, + debug: options.debug, + }); + return options.chunks.map( + (_: MemoryChunk, index: number) => byCustomId.get(String(index)) ?? [], + ); + }, + }, + }; +} diff --git a/extensions/memory-core/src/memory/test-helpers/ssrf.ts b/extensions/memory-core/src/memory/test-helpers/ssrf.ts new file mode 100644 index 00000000000..387374222cc --- /dev/null +++ b/extensions/memory-core/src/memory/test-helpers/ssrf.ts @@ -0,0 +1,34 @@ +import * as ssrf from "openclaw/plugin-sdk/ssrf-runtime"; +import { vi } from "vitest"; + +export function mockPublicPinnedHostname() { + return vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34"]; + const lookup = ((host: string, options?: unknown, callback?: unknown) => { + const cb = + typeof options === "function" + ? (options as (err: NodeJS.ErrnoException | null, address: unknown) => void) + : (callback as (err: NodeJS.ErrnoException | null, address: unknown) => void); + if (!cb) { + return; + } + if (host.trim().toLowerCase().replace(/\.$/, "") !== normalized) { + cb(null, []); + return; + } + cb( + null, + addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })), + ); + }) as never; + return { + hostname: normalized, + addresses, + lookup, + }; + }); +} diff --git a/src/memory/test-manager-helpers.ts b/extensions/memory-core/src/memory/test-manager-helpers.ts similarity index 74% rename from src/memory/test-manager-helpers.ts rename to extensions/memory-core/src/memory/test-manager-helpers.ts index 00ea84b09b0..f8f31e18d0a 100644 --- a/src/memory/test-manager-helpers.ts +++ b/extensions/memory-core/src/memory/test-manager-helpers.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { MemoryIndexManager } from "../plugin-sdk/memory-core.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import type { MemoryIndexManager } from "./index.js"; export async function getRequiredMemoryIndexManager(params: { cfg: OpenClawConfig; @@ -7,7 +7,7 @@ export async function getRequiredMemoryIndexManager(params: { purpose?: "default" | "status"; }): Promise { await import("./embedding.test-mocks.js"); - const { getMemorySearchManager } = await import("../plugin-sdk/memory-core.js"); + const { getMemorySearchManager } = await import("./index.js"); const result = await getMemorySearchManager({ cfg: params.cfg, agentId: params.agentId ?? "main", diff --git a/src/memory/test-manager.ts b/extensions/memory-core/src/memory/test-manager.ts similarity index 61% rename from src/memory/test-manager.ts rename to extensions/memory-core/src/memory/test-manager.ts index 8bd28afc04b..5ec7ddd3235 100644 --- a/src/memory/test-manager.ts +++ b/extensions/memory-core/src/memory/test-manager.ts @@ -1,6 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { getMemorySearchManager } from "../plugin-sdk/memory-core.js"; -import type { MemoryIndexManager } from "../plugin-sdk/memory-core.js"; +import type { OpenClawConfig } from "../engine-host-api.js"; +import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; export async function createMemoryManagerOrThrow( cfg: OpenClawConfig, diff --git a/src/memory/test-runtime-mocks.ts b/extensions/memory-core/src/memory/test-runtime-mocks.ts similarity index 100% rename from src/memory/test-runtime-mocks.ts rename to extensions/memory-core/src/memory/test-runtime-mocks.ts diff --git a/package.json b/package.json index e11f776100f..54d123b902f 100644 --- a/package.json +++ b/package.json @@ -424,6 +424,14 @@ "types": "./dist/plugin-sdk/memory-core-host.d.ts", "default": "./dist/plugin-sdk/memory-core-host.js" }, + "./plugin-sdk/memory-core-host-engine": { + "types": "./dist/plugin-sdk/memory-core-host-engine.d.ts", + "default": "./dist/plugin-sdk/memory-core-host-engine.js" + }, + "./plugin-sdk/memory-core-host-runtime": { + "types": "./dist/plugin-sdk/memory-core-host-runtime.d.ts", + "default": "./dist/plugin-sdk/memory-core-host-runtime.js" + }, "./plugin-sdk/memory-lancedb": { "types": "./dist/plugin-sdk/memory-lancedb.d.ts", "default": "./dist/plugin-sdk/memory-lancedb.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f220b0a4e32..085dd941c84 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -96,6 +96,8 @@ "mattermost", "memory-core", "memory-core-host", + "memory-core-host-engine", + "memory-core-host-runtime", "memory-lancedb", "msteams", "nextcloud-talk", diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index feb0054b302..17f7b99fe3d 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -1,13 +1,64 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { + clearMemoryEmbeddingProviders, + registerMemoryEmbeddingProvider, +} from "../plugins/memory-embedding-providers.js"; import { resolveMemorySearchConfig } from "./memory-search.js"; const asConfig = (cfg: OpenClawConfig): OpenClawConfig => cfg; describe("memory search config", () => { - function configWithDefaultProvider( - provider: "openai" | "local" | "gemini" | "mistral" | "ollama", - ): OpenClawConfig { + beforeEach(() => { + clearMemoryEmbeddingProviders(); + registerMemoryEmbeddingProvider({ + id: "openai", + defaultModel: "text-embedding-3-small", + transport: "remote", + create: async () => ({ provider: null }), + }); + registerMemoryEmbeddingProvider({ + id: "local", + defaultModel: "local-default", + transport: "local", + create: async () => ({ provider: null }), + }); + registerMemoryEmbeddingProvider({ + id: "gemini", + defaultModel: "gemini-embedding-001", + transport: "remote", + supportsMultimodalEmbeddings: ({ model }) => + model + .trim() + .replace(/^models\//, "") + .replace(/^(gemini|google)\//, "") === "gemini-embedding-2-preview", + create: async () => ({ provider: null }), + }); + registerMemoryEmbeddingProvider({ + id: "voyage", + defaultModel: "voyage-4-large", + transport: "remote", + create: async () => ({ provider: null }), + }); + registerMemoryEmbeddingProvider({ + id: "mistral", + defaultModel: "mistral-embed", + transport: "remote", + create: async () => ({ provider: null }), + }); + registerMemoryEmbeddingProvider({ + id: "ollama", + defaultModel: "nomic-embed-text", + transport: "remote", + create: async () => ({ provider: null }), + }); + }); + + afterEach(() => { + clearMemoryEmbeddingProviders(); + }); + + function configWithDefaultProvider(provider: string): OpenClawConfig { return asConfig({ agents: { defaults: { @@ -258,7 +309,7 @@ describe("memory search config", () => { }, }); expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow( - /memorySearch\.multimodal requires memorySearch\.provider = "gemini"/, + /memorySearch\.multimodal requires a provider adapter that supports multimodal embeddings/, ); }); diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 1cbc83b7781..cdc7b0b30b4 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -3,12 +3,12 @@ import path from "node:path"; import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { SecretInput } from "../config/types.secrets.js"; +import { getMemoryEmbeddingProvider } from "../plugins/memory-embedding-providers.js"; import { isMemoryMultimodalEnabled, normalizeMemoryMultimodalSettings, - supportsMemoryMultimodalEmbeddings, type MemoryMultimodalSettings, -} from "../memory/multimodal.js"; +} from "../plugins/memory-host/multimodal.js"; import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; import { resolveAgentConfig } from "./agent-scope.js"; @@ -17,7 +17,7 @@ export type ResolvedMemorySearchConfig = { sources: Array<"memory" | "sessions">; extraPaths: string[]; multimodal: MemoryMultimodalSettings; - provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto"; + provider: string; remote?: { baseUrl?: string; apiKey?: SecretInput; @@ -33,7 +33,7 @@ export type ResolvedMemorySearchConfig = { experimental: { sessionMemory: boolean; }; - fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; + fallback: string; model: string; outputDimensionality?: number; local: { @@ -88,11 +88,6 @@ export type ResolvedMemorySearchConfig = { }; }; -const DEFAULT_OPENAI_MODEL = "text-embedding-3-small"; -const DEFAULT_GEMINI_MODEL = "gemini-embedding-001"; -const DEFAULT_VOYAGE_MODEL = "voyage-4-large"; -const DEFAULT_MISTRAL_MODEL = "mistral-embed"; -const DEFAULT_OLLAMA_MODEL = "nomic-embed-text"; const DEFAULT_CHUNK_TOKENS = 400; const DEFAULT_CHUNK_OVERLAP = 80; const DEFAULT_WATCH_DEBOUNCE_MS = 1500; @@ -150,8 +145,12 @@ function mergeConfig( const sessionMemory = overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false; const provider = overrides?.provider ?? defaults?.provider ?? "auto"; + const primaryAdapter = provider === "auto" ? undefined : getMemoryEmbeddingProvider(provider); const defaultRemote = defaults?.remote; const overrideRemote = overrides?.remote; + const fallback = overrides?.fallback ?? defaults?.fallback ?? "none"; + const fallbackAdapter = + fallback && fallback !== "none" ? getMemoryEmbeddingProvider(fallback) : undefined; const hasRemoteConfig = Boolean( overrideRemote?.baseUrl || overrideRemote?.apiKey || @@ -162,12 +161,9 @@ function mergeConfig( ); const includeRemote = hasRemoteConfig || - provider === "openai" || - provider === "gemini" || - provider === "voyage" || - provider === "mistral" || - provider === "ollama" || - provider === "auto"; + provider === "auto" || + primaryAdapter?.transport !== "local" || + fallbackAdapter?.transport === "remote"; const batch = { enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? false, wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true, @@ -188,19 +184,7 @@ function mergeConfig( batch, } : undefined; - const fallback = overrides?.fallback ?? defaults?.fallback ?? "none"; - const modelDefault = - provider === "gemini" - ? DEFAULT_GEMINI_MODEL - : provider === "openai" - ? DEFAULT_OPENAI_MODEL - : provider === "voyage" - ? DEFAULT_VOYAGE_MODEL - : provider === "mistral" - ? DEFAULT_MISTRAL_MODEL - : provider === "ollama" - ? DEFAULT_OLLAMA_MODEL - : undefined; + const modelDefault = provider === "auto" ? undefined : primaryAdapter?.defaultModel; const model = overrides?.model ?? defaults?.model ?? modelDefault ?? ""; const outputDimensionality = overrides?.outputDimensionality ?? defaults?.outputDimensionality; const local = { @@ -386,15 +370,16 @@ export function resolveMemorySearchConfig( return null; } const multimodalActive = isMemoryMultimodalEnabled(resolved.multimodal); + const multimodalProvider = + resolved.provider === "auto" ? undefined : getMemoryEmbeddingProvider(resolved.provider); if ( multimodalActive && - !supportsMemoryMultimodalEmbeddings({ - provider: resolved.provider, + !multimodalProvider?.supportsMultimodalEmbeddings?.({ model: resolved.model, }) ) { throw new Error( - 'agents.*.memorySearch.multimodal requires memorySearch.provider = "gemini" and model = "gemini-embedding-2-preview".', + "agents.*.memorySearch.multimodal requires a provider adapter that supports multimodal embeddings for the configured model.", ); } if (multimodalActive && resolved.fallback !== "none") { diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 85052728a9c..2dc68abe0f3 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -5,7 +5,10 @@ import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent import { extractSections } from "../../auto-reply/reply/post-compaction-context.js"; import { openBoundaryFile } from "../../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { extractKeywords, isQueryStopWordToken } from "../../memory/query-expansion.js"; +import { + extractKeywords, + isQueryStopWordToken, +} from "../../plugins/memory-host/query-expansion.js"; import { hasMeaningfulConversationContent, isRealConversationMessage, diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index a79efeeeb35..f0367be7756 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -4,8 +4,8 @@ import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { resolveApiKeyForProvider } from "../agents/model-auth.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; -import { DEFAULT_LOCAL_MODEL } from "../memory/embeddings.js"; -import { hasConfiguredMemorySecretInput } from "../memory/secret-input.js"; +import { DEFAULT_LOCAL_MODEL } from "../plugins/memory-host/embeddings.js"; +import { hasConfiguredMemorySecretInput } from "../plugins/memory-host/secret-input.js"; import { resolveActiveMemoryBackendConfig } from "../plugins/memory-runtime.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; @@ -191,7 +191,7 @@ function hasLocalEmbeddings(local: { modelPath?: string }, useDefaultFallback = } async function hasApiKeyForProvider( - provider: "openai" | "gemini" | "voyage" | "mistral" | "ollama", + provider: string, cfg: OpenClawConfig, agentDir: string, ): Promise { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 236b08603ba..2a296382b1e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -12,7 +12,7 @@ import { resolveMemoryFtsState, resolveMemoryVectorState, type Tone, -} from "../memory/status-format.js"; +} from "../plugins/memory-host/status-format.js"; import { formatPluginCompatibilityNotice, summarizePluginCompatibility, diff --git a/src/commands/status.scan.deps.runtime.ts b/src/commands/status.scan.deps.runtime.ts index ce493152307..1001b20a230 100644 --- a/src/commands/status.scan.deps.runtime.ts +++ b/src/commands/status.scan.deps.runtime.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { getTailnetHostname } from "../infra/tailscale.js"; -import type { MemoryProviderStatus } from "../memory/types.js"; +import type { MemoryProviderStatus } from "../plugins/memory-host/types.js"; import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js"; export { getTailnetHostname }; diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index 20326a21a5c..a3926d23a57 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; -import type { MemoryProviderStatus } from "../memory/types.js"; +import type { MemoryProviderStatus } from "../plugins/memory-host/types.js"; import { pickGatewaySelfPresence, resolveGatewayProbeAuthResolution, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index cf26b528d63..1014792e3a3 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -1882,32 +1882,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { additionalProperties: false, }, provider: { - anyOf: [ - { - type: "string", - const: "openai", - }, - { - type: "string", - const: "local", - }, - { - type: "string", - const: "gemini", - }, - { - type: "string", - const: "voyage", - }, - { - type: "string", - const: "mistral", - }, - { - type: "string", - const: "ollama", - }, - ], + type: "string", }, remote: { type: "object", @@ -2021,36 +1996,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { additionalProperties: false, }, fallback: { - anyOf: [ - { - type: "string", - const: "openai", - }, - { - type: "string", - const: "gemini", - }, - { - type: "string", - const: "local", - }, - { - type: "string", - const: "voyage", - }, - { - type: "string", - const: "mistral", - }, - { - type: "string", - const: "ollama", - }, - { - type: "string", - const: "none", - }, - ], + type: "string", }, model: { type: "string", @@ -3499,32 +3445,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { additionalProperties: false, }, provider: { - anyOf: [ - { - type: "string", - const: "openai", - }, - { - type: "string", - const: "local", - }, - { - type: "string", - const: "gemini", - }, - { - type: "string", - const: "voyage", - }, - { - type: "string", - const: "mistral", - }, - { - type: "string", - const: "ollama", - }, - ], + type: "string", }, remote: { type: "object", @@ -3638,36 +3559,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { additionalProperties: false, }, fallback: { - anyOf: [ - { - type: "string", - const: "openai", - }, - { - type: "string", - const: "gemini", - }, - { - type: "string", - const: "local", - }, - { - type: "string", - const: "voyage", - }, - { - type: "string", - const: "mistral", - }, - { - type: "string", - const: "ollama", - }, - { - type: "string", - const: "none", - }, - ], + type: "string", }, model: { type: "string", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index f66d6773045..a0f7fcbee1d 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -340,8 +340,8 @@ export type MemorySearchConfig = { /** Enable session transcript indexing (experimental, default: false). */ sessionMemory?: boolean; }; - /** Embedding provider mode. */ - provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama"; + /** Memory embedding provider adapter id. */ + provider?: string; remote?: { baseUrl?: string; apiKey?: SecretInput; @@ -359,8 +359,8 @@ export type MemorySearchConfig = { timeoutMinutes?: number; }; }; - /** Fallback behavior when embeddings fail. */ - fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; + /** Fallback memory embedding provider adapter id when embeddings fail. */ + fallback?: string; /** Embedding model id (remote) or alias (local). */ model?: string; /** diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 737b824694f..a006711a695 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -606,16 +606,7 @@ export const MemorySearchSchema = z }) .strict() .optional(), - provider: z - .union([ - z.literal("openai"), - z.literal("local"), - z.literal("gemini"), - z.literal("voyage"), - z.literal("mistral"), - z.literal("ollama"), - ]) - .optional(), + provider: z.string().optional(), remote: z .object({ baseUrl: z.string().optional(), @@ -634,17 +625,7 @@ export const MemorySearchSchema = z }) .strict() .optional(), - fallback: z - .union([ - z.literal("openai"), - z.literal("gemini"), - z.literal("local"), - z.literal("voyage"), - z.literal("mistral"), - z.literal("ollama"), - z.literal("none"), - ]) - .optional(), + fallback: z.string().optional(), model: z.string().optional(), outputDimensionality: z.number().int().positive().optional(), local: z diff --git a/src/gateway/embeddings-http.test.ts b/src/gateway/embeddings-http.test.ts index 8bd70cd9d55..8eec15c92fc 100644 --- a/src/gateway/embeddings-http.test.ts +++ b/src/gateway/embeddings-http.test.ts @@ -1,33 +1,60 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveAgentDir } from "../agents/agent-scope.js"; +import type { MemoryEmbeddingProviderAdapter } from "../plugins/memory-embedding-providers.js"; import { getFreePort, installGatewayTestHooks } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); let startGatewayServer: typeof import("./server.js").startGatewayServer; -let createEmbeddingProviderMock: ReturnType; +let createEmbeddingProviderMock: ReturnType< + typeof vi.fn< + (options: { provider: string; model: string; agentDir?: string }) => Promise<{ + provider: { + id: string; + model: string; + embedQuery: (text: string) => Promise; + embedBatch: (texts: string[]) => Promise; + }; + }> + > +>; +let clearMemoryEmbeddingProviders: typeof import("../plugins/memory-embedding-providers.js").clearMemoryEmbeddingProviders; +let registerMemoryEmbeddingProvider: typeof import("../plugins/memory-embedding-providers.js").registerMemoryEmbeddingProvider; let enabledServer: Awaited>; let enabledPort: number; beforeAll(async () => { vi.resetModules(); - createEmbeddingProviderMock = vi.fn(async (options: { provider: string; model: string }) => ({ - provider: { - id: options.provider, - model: options.model, - embedQuery: async () => [0.1, 0.2], - embedBatch: async (texts: string[]) => - texts.map((_text, index) => [index + 0.1, index + 0.2]), + ({ clearMemoryEmbeddingProviders, registerMemoryEmbeddingProvider } = + await import("../plugins/memory-embedding-providers.js")); + createEmbeddingProviderMock = vi.fn( + async (options: { provider: string; model: string; agentDir?: string }) => ({ + provider: { + id: options.provider, + model: options.model, + embedQuery: async () => [0.1, 0.2], + embedBatch: async (texts: string[]) => + texts.map((_text, index) => [index + 0.1, index + 0.2]), + }, + }), + ); + clearMemoryEmbeddingProviders(); + const openAiAdapter: MemoryEmbeddingProviderAdapter = { + id: "openai", + defaultModel: "text-embedding-3-small", + transport: "remote", + autoSelectPriority: 20, + allowExplicitWhenConfiguredAuto: true, + create: async (options) => { + const result = await createEmbeddingProviderMock({ + provider: "openai", + model: options.model, + agentDir: options.agentDir, + }); + return result; }, - })); - vi.doMock("../memory/embeddings.js", async () => { - const actual = - await vi.importActual("../memory/embeddings.js"); - return { - ...actual, - createEmbeddingProvider: createEmbeddingProviderMock, - }; - }); + }; + registerMemoryEmbeddingProvider(openAiAdapter); ({ startGatewayServer } = await import("./server.js")); enabledPort = await getFreePort(); enabledServer = await startServer(enabledPort, { openAiChatCompletionsEnabled: true }); @@ -35,6 +62,7 @@ beforeAll(async () => { afterAll(async () => { await enabledServer.close({ reason: "embeddings http enabled suite done" }); + clearMemoryEmbeddingProviders(); vi.resetModules(); }); @@ -120,10 +148,9 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { expect(typeof json.data?.[0]?.embedding).toBe("string"); expect(createEmbeddingProviderMock).toHaveBeenCalled(); const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as - | { provider?: string; model?: string; fallback?: string; agentDir?: string } + | { provider?: string; model?: string; agentDir?: string } | undefined; expect(typeof lastCall?.model).toBe("string"); - expect(lastCall?.fallback).toBe("none"); expect(lastCall?.agentDir).toBe(resolveAgentDir({}, "beta")); }); diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index bb11fdb0006..a67501b87cf 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -5,11 +5,11 @@ import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadConfig } from "../config/config.js"; import { logWarn } from "../logger.js"; import { - createEmbeddingProvider, - type EmbeddingProviderOptions, - type EmbeddingProviderId, - type EmbeddingProviderRequest, -} from "../memory/embeddings.js"; + getMemoryEmbeddingProvider, + listMemoryEmbeddingProviders, + type MemoryEmbeddingProvider, + type MemoryEmbeddingProviderAdapter, +} from "../plugins/memory-embedding-providers.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson } from "./http-common.js"; @@ -41,12 +41,7 @@ const DEFAULT_EMBEDDINGS_BODY_BYTES = 5 * 1024 * 1024; const MAX_EMBEDDING_INPUTS = 128; const MAX_EMBEDDING_INPUT_CHARS = 8_192; const MAX_EMBEDDING_TOTAL_CHARS = 65_536; -const SAFE_AUTO_EXPLICIT_PROVIDERS = new Set([ - "openai", - "gemini", - "voyage", - "mistral", -]); +type EmbeddingProviderRequest = string; function coerceRequest(value: unknown): EmbeddingsRequest { return value && typeof value === "object" ? (value as EmbeddingsRequest) : {}; @@ -87,6 +82,88 @@ function validateInputTexts(texts: string[]): string | undefined { return undefined; } +function formatErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function resolveAutoExplicitProviders(): Set { + return new Set( + listMemoryEmbeddingProviders() + .filter((adapter) => adapter.allowExplicitWhenConfiguredAuto) + .map((adapter) => adapter.id), + ); +} + +function shouldContinueAutoSelection( + adapter: MemoryEmbeddingProviderAdapter, + err: unknown, +): boolean { + return adapter.shouldContinueAutoSelection?.(err) ?? false; +} + +async function createConfiguredEmbeddingProvider(params: { + cfg: ReturnType; + agentDir: string; + provider: EmbeddingProviderRequest; + model: string; + memorySearch?: Pick< + NonNullable>, + "local" | "remote" | "outputDimensionality" + >; +}): Promise { + const createWithAdapter = async (adapter: MemoryEmbeddingProviderAdapter) => { + const result = await adapter.create({ + config: params.cfg, + agentDir: params.agentDir, + model: params.model || adapter.defaultModel || "", + local: params.memorySearch?.local, + remote: params.memorySearch?.remote + ? { + baseUrl: params.memorySearch?.remote.baseUrl, + apiKey: params.memorySearch?.remote.apiKey, + headers: params.memorySearch?.remote.headers, + } + : undefined, + outputDimensionality: params.memorySearch?.outputDimensionality, + }); + return result.provider; + }; + + if (params.provider === "auto") { + const adapters = listMemoryEmbeddingProviders() + .filter((adapter) => typeof adapter.autoSelectPriority === "number") + .toSorted( + (a, b) => + (a.autoSelectPriority ?? Number.MAX_SAFE_INTEGER) - + (b.autoSelectPriority ?? Number.MAX_SAFE_INTEGER), + ); + for (const adapter of adapters) { + try { + const provider = await createWithAdapter(adapter); + if (provider) { + return provider; + } + } catch (err) { + if (shouldContinueAutoSelection(adapter, err)) { + continue; + } + throw err; + } + } + throw new Error("No embeddings provider available."); + } + + const adapter = getMemoryEmbeddingProvider(params.provider); + if (!adapter) { + throw new Error(`Unknown memory embedding provider: ${params.provider}`); + } + const provider = await createWithAdapter(adapter); + if (!provider) { + throw new Error(`Memory embedding provider ${params.provider} is unavailable.`); + } + return provider; +} + function resolveEmbeddingsTarget(params: { requestModel: string; configuredProvider: EmbeddingProviderRequest; @@ -97,17 +174,18 @@ function resolveEmbeddingsTarget(params: { return { provider: params.configuredProvider, model: raw }; } - const provider = raw.slice(0, slash).trim().toLowerCase() as EmbeddingProviderRequest; + const provider = raw.slice(0, slash).trim().toLowerCase(); const model = raw.slice(slash + 1).trim(); if (!model) { return { errorMessage: "Unsupported embedding model reference." }; } if (params.configuredProvider === "auto") { + const safeAutoExplicitProviders = resolveAutoExplicitProviders(); if (provider === "auto") { return { provider: "auto", model }; } - if (SAFE_AUTO_EXPLICIT_PROVIDERS.has(provider)) { + if (safeAutoExplicitProviders.has(provider)) { return { provider, model }; } return { @@ -185,7 +263,7 @@ export async function handleOpenAiEmbeddingsHttpRequest( const agentId = resolveAgentIdForRequest({ req, model: requestModel }); const agentDir = resolveAgentDir(cfg, agentId); const memorySearch = resolveMemorySearchConfig(cfg, agentId); - const configuredProvider = (memorySearch?.provider ?? "openai") as EmbeddingProviderRequest; + const configuredProvider = memorySearch?.provider ?? "openai"; const overrideModel = getHeader(req, "x-openclaw-model")?.trim() || memorySearch?.model || ""; const target = resolveEmbeddingsTarget({ requestModel: overrideModel, configuredProvider }); if ("errorMessage" in target) { @@ -198,41 +276,23 @@ export async function handleOpenAiEmbeddingsHttpRequest( return true; } - const options: EmbeddingProviderOptions = { - config: cfg, - agentDir, - provider: target.provider, - model: target.model, - // Public HTTP embeddings should fail closed rather than silently mixing - // vector spaces across fallback providers/models. - fallback: "none", - local: memorySearch?.local, - remote: memorySearch?.remote - ? { - baseUrl: memorySearch.remote.baseUrl, - apiKey: memorySearch.remote.apiKey, - headers: memorySearch.remote.headers, - } - : undefined, - outputDimensionality: - typeof payload.dimensions === "number" && payload.dimensions > 0 - ? Math.floor(payload.dimensions) - : memorySearch?.outputDimensionality, - }; - try { - const result = await createEmbeddingProvider(options); - if (!result.provider) { - sendJson(res, 503, { - error: { - message: result.providerUnavailableReason ?? "Embeddings provider unavailable.", - type: "api_error", - }, - }); - return true; - } - - const embeddings = await result.provider.embedBatch(texts); + const provider = await createConfiguredEmbeddingProvider({ + cfg, + agentDir, + provider: target.provider, + model: target.model, + memorySearch: memorySearch + ? { + ...memorySearch, + outputDimensionality: + typeof payload.dimensions === "number" && payload.dimensions > 0 + ? Math.floor(payload.dimensions) + : memorySearch.outputDimensionality, + } + : undefined, + }); + const embeddings = await provider.embedBatch(texts); const encodingFormat = payload.encoding_format === "base64" ? "base64" : "float"; sendJson(res, 200, { @@ -249,7 +309,7 @@ export async function handleOpenAiEmbeddingsHttpRequest( }, }); } catch (err) { - logWarn(`openai-compat: embeddings request failed: ${String(err)}`); + logWarn(`openai-compat: embeddings request failed: ${formatErrorMessage(err)}`); sendJson(res, 500, { error: { message: "internal error", diff --git a/src/memory/test-embeddings-mock.ts b/src/memory/test-embeddings-mock.ts deleted file mode 100644 index 5d2d4220cbb..00000000000 --- a/src/memory/test-embeddings-mock.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function createOpenAIEmbeddingProviderMock(params: { - embedQuery: (input: string) => Promise; - embedBatch: (input: string[]) => Promise; -}) { - return { - requestedProvider: "openai", - provider: { - id: "openai", - model: "text-embedding-3-small", - embedQuery: params.embedQuery, - embedBatch: params.embedBatch, - }, - openAi: { - baseUrl: "https://api.openai.com/v1", - headers: { Authorization: "Bearer test", "Content-Type": "application/json" }, - model: "text-embedding-3-small", - }, - }; -} diff --git a/src/plugin-sdk/memory-core-host-engine.ts b/src/plugin-sdk/memory-core-host-engine.ts new file mode 100644 index 00000000000..30544cff364 --- /dev/null +++ b/src/plugin-sdk/memory-core-host-engine.ts @@ -0,0 +1,168 @@ +// Narrow engine surface for the bundled memory-core plugin. +// Keep this limited to host utilities needed by the memory engine cluster. + +export { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, + resolveSessionAgentId, +} from "../agents/agent-scope.js"; +export { + resolveMemorySearchConfig, + type ResolvedMemorySearchConfig, +} from "../agents/memory-search.js"; +export { parseDurationMs } from "../cli/parse-duration.js"; +export { loadConfig } from "../config/config.js"; +export { resolveStateDir } from "../config/paths.js"; +export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; +export { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, +} from "../config/types.secrets.js"; +export { writeFileWithinRoot } from "../infra/fs-safe.js"; +export { createSubsystemLogger } from "../logging/subsystem.js"; +export { resolveGlobalSingleton } from "../shared/global-singleton.js"; +export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; +export { + buildFileEntry, + buildMultimodalChunkForIndexing, + chunkMarkdown, + cosineSimilarity, + ensureDir, + hashText, + listMemoryFiles, + normalizeExtraMemoryPaths, + parseEmbedding, + remapChunkLines, + runWithConcurrency, + type MemoryChunk, + type MemoryFileEntry, +} from "../plugins/memory-host/internal.js"; +export { readMemoryFile } from "../plugins/memory-host/read-file.js"; +export { resolveMemoryBackendConfig } from "../plugins/memory-host/backend-config.js"; +export type { + ResolvedMemoryBackendConfig, + ResolvedQmdConfig, + ResolvedQmdMcporterConfig, +} from "../plugins/memory-host/backend-config.js"; +export type { + MemoryEmbeddingProbeResult, + MemoryProviderStatus, + MemorySearchManager, + MemorySearchResult, + MemorySource, + MemorySyncProgressUpdate, +} from "../plugins/memory-host/types.js"; +export { + getMemoryEmbeddingProvider, + listMemoryEmbeddingProviders, +} from "../plugins/memory-embedding-providers.js"; +export type { + MemoryEmbeddingBatchChunk, + MemoryEmbeddingBatchOptions, + MemoryEmbeddingProvider, + MemoryEmbeddingProviderAdapter, + MemoryEmbeddingProviderCreateOptions, + MemoryEmbeddingProviderCreateResult, + MemoryEmbeddingProviderRuntime, +} from "../plugins/memory-embedding-providers.js"; +export { + createLocalEmbeddingProvider, + createEmbeddingProvider, + DEFAULT_LOCAL_MODEL, + type EmbeddingProvider, + type EmbeddingProviderRequest, + type EmbeddingProviderResult, + type GeminiEmbeddingClient, + type MistralEmbeddingClient, + type OllamaEmbeddingClient, + type OpenAiEmbeddingClient, + type VoyageEmbeddingClient, +} from "../plugins/memory-host/embeddings.js"; +export { + createGeminiEmbeddingProvider, + DEFAULT_GEMINI_EMBEDDING_MODEL, + buildGeminiEmbeddingRequest, +} from "../plugins/memory-host/embeddings-gemini.js"; +export { + createMistralEmbeddingProvider, + DEFAULT_MISTRAL_EMBEDDING_MODEL, +} from "../plugins/memory-host/embeddings-mistral.js"; +export { + createOllamaEmbeddingProvider, + DEFAULT_OLLAMA_EMBEDDING_MODEL, +} from "../plugins/memory-host/embeddings-ollama.js"; +export { + createOpenAiEmbeddingProvider, + DEFAULT_OPENAI_EMBEDDING_MODEL, +} from "../plugins/memory-host/embeddings-openai.js"; +export { + createVoyageEmbeddingProvider, + DEFAULT_VOYAGE_EMBEDDING_MODEL, +} from "../plugins/memory-host/embeddings-voyage.js"; +export { + runGeminiEmbeddingBatches, + type GeminiBatchRequest, +} from "../plugins/memory-host/batch-gemini.js"; +export { + OPENAI_BATCH_ENDPOINT, + runOpenAiEmbeddingBatches, + type OpenAiBatchRequest, +} from "../plugins/memory-host/batch-openai.js"; +export { + runVoyageEmbeddingBatches, + type VoyageBatchRequest, +} from "../plugins/memory-host/batch-voyage.js"; +export { enforceEmbeddingMaxInputTokens } from "../plugins/memory-host/embedding-chunk-limits.js"; +export { + estimateStructuredEmbeddingInputBytes, + estimateUtf8Bytes, +} from "../plugins/memory-host/embedding-input-limits.js"; +export { + hasNonTextEmbeddingParts, + type EmbeddingInput, +} from "../plugins/memory-host/embedding-inputs.js"; +export { + buildCaseInsensitiveExtensionGlob, + classifyMemoryMultimodalPath, + getMemoryMultimodalExtensions, +} from "../plugins/memory-host/multimodal.js"; +export { ensureMemoryIndexSchema } from "../plugins/memory-host/memory-schema.js"; +export { loadSqliteVecExtension } from "../plugins/memory-host/sqlite-vec.js"; +export { requireNodeSqlite } from "../plugins/memory-host/sqlite.js"; +export { extractKeywords, isQueryStopWordToken } from "../plugins/memory-host/query-expansion.js"; +export { + buildSessionEntry, + listSessionFilesForAgent, + sessionPathForFile, + type SessionFileEntry, +} from "../plugins/memory-host/session-files.js"; +export { parseQmdQueryJson, type QmdQueryResult } from "../plugins/memory-host/qmd-query-parser.js"; +export { + deriveQmdScopeChannel, + deriveQmdScopeChatType, + isQmdScopeAllowed, +} from "../plugins/memory-host/qmd-scope.js"; +export { isFileMissingError, statRegularFile } from "../plugins/memory-host/fs-utils.js"; +export { resolveCliSpawnInvocation, runCliCommand } from "../plugins/memory-host/qmd-process.js"; +export { detectMime } from "../media/mime.js"; +export { splitShellArgs } from "../utils/shell-argv.js"; +export { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; +export { + shortenHomeInString, + shortenHomePath, + resolveUserPath, + truncateUtf16Safe, +} from "../utils.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { SessionSendPolicyConfig } from "../config/types.base.js"; +export type { + MemoryBackend, + MemoryCitationsMode, + MemoryQmdConfig, + MemoryQmdIndexPath, + MemoryQmdMcporterConfig, + MemoryQmdSearchMode, +} from "../config/types.memory.js"; +export type { MemorySearchConfig } from "../config/types.tools.js"; +export type { SecretInput } from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/memory-core-host-runtime.ts b/src/plugin-sdk/memory-core-host-runtime.ts new file mode 100644 index 00000000000..2a2eedd44b7 --- /dev/null +++ b/src/plugin-sdk/memory-core-host-runtime.ts @@ -0,0 +1,38 @@ +// Narrow runtime/helper surface for the bundled memory-core plugin. +// Keep this focused on non-engine plugin wiring: CLI, tools, prompt, flush. + +export type { AnyAgentTool } from "../agents/tools/common.js"; +export { resolveCronStyleNow } from "../agents/current-time.js"; +export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js"; +export { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js"; +export { resolveMemorySearchConfig } from "../agents/memory-search.js"; +export { jsonResult, readNumberParam, readStringParam } from "../agents/tools/common.js"; +export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +export { formatErrorMessage, withManager } from "../cli/cli-utils.js"; +export { formatHelpExamples } from "../cli/help-format.js"; +export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; +export { withProgress, withProgressTotals } from "../cli/progress.js"; +export { parseNonNegativeByteSize } from "../config/byte-size.js"; +export { loadConfig } from "../config/config.js"; +export { resolveStateDir } from "../config/paths.js"; +export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; +export { listMemoryFiles, normalizeExtraMemoryPaths } from "../plugins/memory-host/internal.js"; +export { readAgentMemoryFile } from "../plugins/memory-host/read-file.js"; +export { resolveMemoryBackendConfig } from "../plugins/memory-host/backend-config.js"; +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { parseAgentSessionKey } from "../routing/session-key.js"; +export { defaultRuntime } from "../runtime.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { colorize, isRich, theme } from "../terminal/theme.js"; +export { isVerbose, setVerbose } from "../globals.js"; +export { shortenHomeInString, shortenHomePath } from "../utils.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { MemoryCitationsMode } from "../config/types.memory.js"; +export type { MemorySearchResult } from "../plugins/memory-host/types.js"; +export type { + MemoryFlushPlan, + MemoryFlushPlanResolver, + MemoryPluginRuntime, + MemoryPromptSectionBuilder, +} from "../plugins/memory-state.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/memory-core-host.ts b/src/plugin-sdk/memory-core-host.ts index 6927e9184c3..63dd994865f 100644 --- a/src/plugin-sdk/memory-core-host.ts +++ b/src/plugin-sdk/memory-core-host.ts @@ -1,153 +1,2 @@ -// Narrow helper surface for the bundled memory-core plugin implementation. -// Keep this focused on generic host seams and shared utilities. - -export type { AnyAgentTool } from "../agents/tools/common.js"; -export { resolveCronStyleNow } from "../agents/current-time.js"; -export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js"; -export { - resolveAgentDir, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../agents/agent-scope.js"; -export { - resolveMemorySearchConfig, - type ResolvedMemorySearchConfig, -} from "../agents/memory-search.js"; -export { jsonResult, readNumberParam, readStringParam } from "../agents/tools/common.js"; -export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -export { formatErrorMessage, withManager } from "../cli/cli-utils.js"; -export { formatHelpExamples } from "../cli/help-format.js"; -export { parseDurationMs } from "../cli/parse-duration.js"; -export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; -export { withProgress, withProgressTotals } from "../cli/progress.js"; -export { parseNonNegativeByteSize } from "../config/byte-size.js"; -export { loadConfig } from "../config/config.js"; -export { resolveStateDir } from "../config/paths.js"; -export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; -export { writeFileWithinRoot } from "../infra/fs-safe.js"; -export { createSubsystemLogger } from "../logging/subsystem.js"; -export { resolveGlobalSingleton } from "../shared/global-singleton.js"; -export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; -export { - buildFileEntry, - buildMultimodalChunkForIndexing, - chunkMarkdown, - cosineSimilarity, - ensureDir, - hashText, - listMemoryFiles, - normalizeExtraMemoryPaths, - parseEmbedding, - remapChunkLines, - runWithConcurrency, - type MemoryChunk, - type MemoryFileEntry, -} from "../memory/internal.js"; -export { readAgentMemoryFile, readMemoryFile } from "../memory/read-file.js"; -export { resolveMemoryBackendConfig } from "../memory/backend-config.js"; -export type { - ResolvedMemoryBackendConfig, - ResolvedQmdConfig, - ResolvedQmdMcporterConfig, -} from "../memory/backend-config.js"; -export type { - MemoryEmbeddingProbeResult, - MemoryProviderStatus, - MemorySearchManager, - MemorySearchResult, - MemorySource, - MemorySyncProgressUpdate, -} from "../memory/types.js"; -export { - createEmbeddingProvider, - type EmbeddingProvider, - type EmbeddingProviderRequest, - type EmbeddingProviderResult, - type GeminiEmbeddingClient, - type MistralEmbeddingClient, - type OllamaEmbeddingClient, - type OpenAiEmbeddingClient, - type VoyageEmbeddingClient, -} from "../memory/embeddings.js"; -export { - DEFAULT_GEMINI_EMBEDDING_MODEL, - buildGeminiEmbeddingRequest, -} from "../memory/embeddings-gemini.js"; -export { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "../memory/embeddings-mistral.js"; -export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "../memory/embeddings-ollama.js"; -export { DEFAULT_OPENAI_EMBEDDING_MODEL } from "../memory/embeddings-openai.js"; -export { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "../memory/embeddings-voyage.js"; -export { runGeminiEmbeddingBatches, type GeminiBatchRequest } from "../memory/batch-gemini.js"; -export { - OPENAI_BATCH_ENDPOINT, - runOpenAiEmbeddingBatches, - type OpenAiBatchRequest, -} from "../memory/batch-openai.js"; -export { runVoyageEmbeddingBatches, type VoyageBatchRequest } from "../memory/batch-voyage.js"; -export { enforceEmbeddingMaxInputTokens } from "../memory/embedding-chunk-limits.js"; -export { - estimateStructuredEmbeddingInputBytes, - estimateUtf8Bytes, -} from "../memory/embedding-input-limits.js"; -export { hasNonTextEmbeddingParts, type EmbeddingInput } from "../memory/embedding-inputs.js"; -export { - buildCaseInsensitiveExtensionGlob, - classifyMemoryMultimodalPath, - getMemoryMultimodalExtensions, -} from "../memory/multimodal.js"; -export { ensureMemoryIndexSchema } from "../memory/memory-schema.js"; -export { loadSqliteVecExtension } from "../memory/sqlite-vec.js"; -export { requireNodeSqlite } from "../memory/sqlite.js"; -export { extractKeywords, isQueryStopWordToken } from "../memory/query-expansion.js"; -export { - buildSessionEntry, - listSessionFilesForAgent, - sessionPathForFile, - type SessionFileEntry, -} from "../memory/session-files.js"; -export { parseQmdQueryJson, type QmdQueryResult } from "../memory/qmd-query-parser.js"; -export { - deriveQmdScopeChannel, - deriveQmdScopeChatType, - isQmdScopeAllowed, -} from "../memory/qmd-scope.js"; -export { isFileMissingError, statRegularFile } from "../memory/fs-utils.js"; -export { resolveCliSpawnInvocation, runCliCommand } from "../memory/qmd-process.js"; -export { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, -} from "../config/types.secrets.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { parseAgentSessionKey } from "../routing/session-key.js"; -export { defaultRuntime } from "../runtime.js"; -export { colorize, isRich, theme } from "../terminal/theme.js"; -export { formatDocsLink } from "../terminal/links.js"; -export { detectMime } from "../media/mime.js"; -export { setVerbose, isVerbose } from "../globals.js"; -export { - shortenHomeInString, - shortenHomePath, - resolveUserPath, - truncateUtf16Safe, -} from "../utils.js"; -export { splitShellArgs } from "../utils/shell-argv.js"; -export { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { SessionSendPolicyConfig } from "../config/types.base.js"; -export type { - MemoryBackend, - MemoryCitationsMode, - MemoryQmdConfig, - MemoryQmdIndexPath, - MemoryQmdMcporterConfig, - MemoryQmdSearchMode, -} from "../config/types.memory.js"; -export type { SecretInput } from "../config/types.secrets.js"; -export type { - MemoryFlushPlan, - MemoryFlushPlanResolver, - MemoryPluginRuntime, - MemoryPromptSectionBuilder, -} from "../plugins/memory-state.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; +export * from "./memory-core-host-runtime.js"; +export * from "./memory-core-host-engine.js"; diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index b422f6e2636..5b8ff72f17e 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -18,9 +18,12 @@ export { getMemorySearchManager, MemoryIndexManager, } from "../../extensions/memory-core/src/memory/index.js"; -export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; -export { readAgentMemoryFile } from "../memory/read-file.js"; -export { resolveMemoryBackendConfig } from "../memory/backend-config.js"; +export { + listMemoryFiles, + normalizeExtraMemoryPaths, + readAgentMemoryFile, + resolveMemoryBackendConfig, +} from "./memory-core-host-runtime.js"; export { setVerbose, isVerbose } from "../globals.js"; export { defaultRuntime } from "../runtime.js"; export { colorize, isRich, theme } from "../terminal/theme.js"; @@ -32,7 +35,7 @@ export { withProgress, withProgressTotals } from "../cli/progress.js"; export { shortenHomeInString, shortenHomePath } from "../utils.js"; export type { OpenClawConfig } from "../config/config.js"; export type { MemoryCitationsMode } from "../config/types.memory.js"; -export type { MemorySearchResult } from "../memory/types.js"; +export type { MemorySearchResult } from "./memory-core-host-runtime.js"; export type { MemoryFlushPlan, MemoryFlushPlanResolver, diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 7908aa2bd1b..55c8e3028a1 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -59,7 +59,8 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration { registerTool(tool: AnyAgentTool) { tools.push(tool); }, - } as OpenClawPluginApi, + registerMemoryEmbeddingProvider() {}, + } as unknown as OpenClawPluginApi, }; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 4d2054ac20e..c87f8d46f6f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -10,6 +10,11 @@ import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global import { createHookRunner } from "./hooks.js"; import { __testing, clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; +import { + getMemoryEmbeddingProvider, + listMemoryEmbeddingProviders, + registerMemoryEmbeddingProvider, +} from "./memory-embedding-providers.js"; import { buildMemoryPromptSection, getMemoryRuntime, @@ -1057,6 +1062,10 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip it("does not replace active memory plugin registries during non-activating loads", () => { useNoBundledPlugins(); + registerMemoryEmbeddingProvider({ + id: "active", + create: async () => ({ provider: null }), + }); registerMemoryPromptSection(() => ["active memory section"]); registerMemoryFlushPlanResolver(() => ({ softThresholdTokens: 1, @@ -1082,6 +1091,10 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip id: "snapshot-memory", kind: "memory", register(api) { + api.registerMemoryEmbeddingProvider({ + id: "snapshot", + create: async () => ({ provider: null }), + }); api.registerMemoryPromptSection(() => ["snapshot memory section"]); api.registerMemoryFlushPlan(() => ({ softThresholdTokens: 10, @@ -1123,6 +1136,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip ]); expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/active.md"); expect(getMemoryRuntime()).toBe(activeRuntime); + expect(listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual(["active"]); }); it("clears newly-registered memory plugin registries when plugin register fails", () => { @@ -1134,6 +1148,10 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip id: "failing-memory", kind: "memory", register(api) { + api.registerMemoryEmbeddingProvider({ + id: "failed", + create: async () => ({ provider: null }), + }); api.registerMemoryPromptSection(() => ["stale failure section"]); api.registerMemoryFlushPlan(() => ({ softThresholdTokens: 10, @@ -1173,6 +1191,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); expect(resolveMemoryFlushPlan({})).toBeNull(); expect(getMemoryRuntime()).toBeUndefined(); + expect(listMemoryEmbeddingProviders()).toEqual([]); }); it("throws when activate:false is used without cache:false", () => { @@ -3435,6 +3454,10 @@ export const runtimeValue = helperValue;`, describe("clearPluginLoaderCache", () => { it("resets registered memory plugin registries", () => { + registerMemoryEmbeddingProvider({ + id: "stale", + create: async () => ({ provider: null }), + }); registerMemoryPromptSection(() => ["stale memory section"]); registerMemoryFlushPlanResolver(() => ({ softThresholdTokens: 1, @@ -3457,11 +3480,13 @@ describe("clearPluginLoaderCache", () => { ]); expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md"); expect(getMemoryRuntime()).toBeDefined(); + expect(getMemoryEmbeddingProvider("stale")).toBeDefined(); clearPluginLoaderCache(); expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); expect(resolveMemoryFlushPlan({})).toBeNull(); expect(getMemoryRuntime()).toBeUndefined(); + expect(getMemoryEmbeddingProvider("stale")).toBeUndefined(); }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b21c7f8fe1a..6c23be7a190 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -22,6 +22,11 @@ import { discoverOpenClawPlugins } from "./discovery.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { clearPluginInteractiveHandlers } from "./interactive.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { + clearMemoryEmbeddingProviders, + listMemoryEmbeddingProviders, + restoreMemoryEmbeddingProviders, +} from "./memory-embedding-providers.js"; import { clearMemoryPluginState, getMemoryFlushPlanResolver, @@ -99,6 +104,7 @@ export class PluginLoadFailureError extends Error { type CachedPluginState = { registry: PluginRegistry; + memoryEmbeddingProviders: ReturnType; memoryFlushPlanResolver: ReturnType; memoryPromptBuilder: ReturnType; memoryRuntime: ReturnType; @@ -127,6 +133,7 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [ export function clearPluginLoaderCache(): void { registryCache.clear(); openAllowlistWarningCache.clear(); + clearMemoryEmbeddingProviders(); clearMemoryPluginState(); } @@ -711,6 +718,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); if (cached) { + restoreMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); restoreMemoryPluginState({ promptBuilder: cached.memoryPromptBuilder, flushPlanResolver: cached.memoryFlushPlanResolver, @@ -1226,6 +1234,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); + const previousMemoryEmbeddingProviders = listMemoryEmbeddingProviders(); const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver(); const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder(); const previousMemoryRuntime = getMemoryRuntime(); @@ -1242,6 +1251,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Snapshot loads should not replace process-global runtime prompt state. if (!shouldActivate) { + restoreMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); restoreMemoryPluginState({ promptBuilder: previousMemoryPromptBuilder, flushPlanResolver: previousMemoryFlushPlanResolver, @@ -1251,6 +1261,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); } catch (err) { + restoreMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); restoreMemoryPluginState({ promptBuilder: previousMemoryPromptBuilder, flushPlanResolver: previousMemoryFlushPlanResolver, @@ -1291,6 +1302,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { setCachedPluginRegistry(cacheKey, { registry, + memoryEmbeddingProviders: listMemoryEmbeddingProviders(), memoryFlushPlanResolver: getMemoryFlushPlanResolver(), memoryPromptBuilder: getMemoryPromptSectionBuilder(), memoryRuntime: getMemoryRuntime(), diff --git a/src/plugins/memory-embedding-providers.test.ts b/src/plugins/memory-embedding-providers.test.ts new file mode 100644 index 00000000000..003a79d7914 --- /dev/null +++ b/src/plugins/memory-embedding-providers.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearMemoryEmbeddingProviders, + getMemoryEmbeddingProvider, + listMemoryEmbeddingProviders, + registerMemoryEmbeddingProvider, + restoreMemoryEmbeddingProviders, + type MemoryEmbeddingProviderAdapter, +} from "./memory-embedding-providers.js"; + +function createAdapter(id: string): MemoryEmbeddingProviderAdapter { + return { + id, + create: async () => ({ provider: null }), + }; +} + +afterEach(() => { + clearMemoryEmbeddingProviders(); +}); + +describe("memory embedding provider registry", () => { + it("registers and lists adapters in insertion order", () => { + registerMemoryEmbeddingProvider(createAdapter("alpha")); + registerMemoryEmbeddingProvider(createAdapter("beta")); + + expect(getMemoryEmbeddingProvider("alpha")?.id).toBe("alpha"); + expect(listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual(["alpha", "beta"]); + }); + + it("restores a previous snapshot", () => { + const alpha = createAdapter("alpha"); + const beta = createAdapter("beta"); + registerMemoryEmbeddingProvider(alpha); + + restoreMemoryEmbeddingProviders([beta]); + + expect(getMemoryEmbeddingProvider("alpha")).toBeUndefined(); + expect(getMemoryEmbeddingProvider("beta")).toBe(beta); + }); + + it("clears the registry", () => { + registerMemoryEmbeddingProvider(createAdapter("alpha")); + + clearMemoryEmbeddingProviders(); + + expect(listMemoryEmbeddingProviders()).toEqual([]); + }); +}); diff --git a/src/plugins/memory-embedding-providers.ts b/src/plugins/memory-embedding-providers.ts new file mode 100644 index 00000000000..5d7208189bb --- /dev/null +++ b/src/plugins/memory-embedding-providers.ts @@ -0,0 +1,95 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import type { EmbeddingInput } from "./memory-host/embedding-inputs.js"; + +export type MemoryEmbeddingBatchChunk = { + text: string; + embeddingInput?: EmbeddingInput; +}; + +export type MemoryEmbeddingBatchOptions = { + agentId: string; + chunks: MemoryEmbeddingBatchChunk[]; + wait: boolean; + concurrency: number; + pollIntervalMs: number; + timeoutMs: number; + debug: (message: string, data?: Record) => void; +}; + +export type MemoryEmbeddingProviderRuntime = { + id: string; + cacheKeyData?: Record; + batchEmbed?: (options: MemoryEmbeddingBatchOptions) => Promise; +}; + +export type MemoryEmbeddingProvider = { + id: string; + model: string; + maxInputTokens?: number; + embedQuery: (text: string) => Promise; + embedBatch: (texts: string[]) => Promise; + embedBatchInputs?: (inputs: EmbeddingInput[]) => Promise; +}; + +export type MemoryEmbeddingProviderCreateOptions = { + config: OpenClawConfig; + agentDir?: string; + remote?: { + baseUrl?: string; + apiKey?: SecretInput; + headers?: Record; + }; + model: string; + local?: { + modelPath?: string; + modelCacheDir?: string; + }; + outputDimensionality?: number; +}; + +export type MemoryEmbeddingProviderCreateResult = { + provider: MemoryEmbeddingProvider | null; + runtime?: MemoryEmbeddingProviderRuntime; +}; + +export type MemoryEmbeddingProviderAdapter = { + id: string; + defaultModel?: string; + transport?: "local" | "remote"; + autoSelectPriority?: number; + allowExplicitWhenConfiguredAuto?: boolean; + supportsMultimodalEmbeddings?: (params: { model: string }) => boolean; + create: ( + options: MemoryEmbeddingProviderCreateOptions, + ) => Promise; + formatSetupError?: (err: unknown) => string; + shouldContinueAutoSelection?: (err: unknown) => boolean; +}; + +const memoryEmbeddingProviders = new Map(); + +export function registerMemoryEmbeddingProvider(adapter: MemoryEmbeddingProviderAdapter): void { + memoryEmbeddingProviders.set(adapter.id, adapter); +} + +export function getMemoryEmbeddingProvider(id: string): MemoryEmbeddingProviderAdapter | undefined { + return memoryEmbeddingProviders.get(id); +} + +export function listMemoryEmbeddingProviders(): MemoryEmbeddingProviderAdapter[] { + return Array.from(memoryEmbeddingProviders.values()); +} + +export function restoreMemoryEmbeddingProviders(adapters: MemoryEmbeddingProviderAdapter[]): void { + memoryEmbeddingProviders.clear(); + for (const adapter of adapters) { + memoryEmbeddingProviders.set(adapter.id, adapter); + } +} + +export function clearMemoryEmbeddingProviders(): void { + memoryEmbeddingProviders.clear(); +} + +export const _resetMemoryEmbeddingProviders = clearMemoryEmbeddingProviders; diff --git a/src/memory/backend-config.test.ts b/src/plugins/memory-host/backend-config.test.ts similarity index 97% rename from src/memory/backend-config.test.ts rename to src/plugins/memory-host/backend-config.test.ts index 61fa62f9316..16fc562d5fc 100644 --- a/src/memory/backend-config.test.ts +++ b/src/plugins/memory-host/backend-config.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js"; describe("resolveMemoryBackendConfig", () => { diff --git a/src/memory/backend-config.ts b/src/plugins/memory-host/backend-config.ts similarity index 96% rename from src/memory/backend-config.ts rename to src/plugins/memory-host/backend-config.ts index da1c13819a3..a7f0a96d964 100644 --- a/src/memory/backend-config.ts +++ b/src/plugins/memory-host/backend-config.ts @@ -1,8 +1,8 @@ import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { SessionSendPolicyConfig } from "../config/types.base.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { parseDurationMs } from "../../cli/parse-duration.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionSendPolicyConfig } from "../../config/types.base.js"; import type { MemoryBackend, MemoryCitationsMode, @@ -10,9 +10,9 @@ import type { MemoryQmdIndexPath, MemoryQmdMcporterConfig, MemoryQmdSearchMode, -} from "../config/types.memory.js"; -import { resolveUserPath } from "../utils.js"; -import { splitShellArgs } from "../utils/shell-argv.js"; +} from "../../config/types.memory.js"; +import { resolveUserPath } from "../../utils.js"; +import { splitShellArgs } from "../../utils/shell-argv.js"; export type ResolvedMemoryBackendConfig = { backend: MemoryBackend; diff --git a/src/memory/batch-embedding-common.ts b/src/plugins/memory-host/batch-embedding-common.ts similarity index 100% rename from src/memory/batch-embedding-common.ts rename to src/plugins/memory-host/batch-embedding-common.ts diff --git a/src/memory/batch-error-utils.test.ts b/src/plugins/memory-host/batch-error-utils.test.ts similarity index 100% rename from src/memory/batch-error-utils.test.ts rename to src/plugins/memory-host/batch-error-utils.test.ts diff --git a/src/memory/batch-error-utils.ts b/src/plugins/memory-host/batch-error-utils.ts similarity index 100% rename from src/memory/batch-error-utils.ts rename to src/plugins/memory-host/batch-error-utils.ts diff --git a/src/memory/batch-gemini.test.ts b/src/plugins/memory-host/batch-gemini.test.ts similarity index 100% rename from src/memory/batch-gemini.test.ts rename to src/plugins/memory-host/batch-gemini.test.ts diff --git a/src/memory/batch-gemini.ts b/src/plugins/memory-host/batch-gemini.ts similarity index 100% rename from src/memory/batch-gemini.ts rename to src/plugins/memory-host/batch-gemini.ts diff --git a/src/memory/batch-http.test.ts b/src/plugins/memory-host/batch-http.test.ts similarity index 92% rename from src/memory/batch-http.test.ts rename to src/plugins/memory-host/batch-http.test.ts index 144c8582e22..e00b50f186b 100644 --- a/src/memory/batch-http.test.ts +++ b/src/plugins/memory-host/batch-http.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../infra/retry.js", () => ({ +vi.mock("../../infra/retry.js", () => ({ retryAsync: vi.fn(async (run: () => Promise) => await run()), })); @@ -9,7 +9,9 @@ vi.mock("./post-json.js", () => ({ })); describe("postJsonWithRetry", () => { - let retryAsyncMock: ReturnType>; + let retryAsyncMock: ReturnType< + typeof vi.mocked + >; let postJsonMock: ReturnType>; let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry; @@ -18,7 +20,7 @@ describe("postJsonWithRetry", () => { vi.clearAllMocks(); vi.resetModules(); ({ postJsonWithRetry } = await import("./batch-http.js")); - const retryModule = await import("../infra/retry.js"); + const retryModule = await import("../../infra/retry.js"); const postJsonModule = await import("./post-json.js"); retryAsyncMock = vi.mocked(retryModule.retryAsync); postJsonMock = vi.mocked(postJsonModule.postJson); diff --git a/src/memory/batch-http.ts b/src/plugins/memory-host/batch-http.ts similarity index 88% rename from src/memory/batch-http.ts rename to src/plugins/memory-host/batch-http.ts index 0610c62e5c5..b098d382f16 100644 --- a/src/memory/batch-http.ts +++ b/src/plugins/memory-host/batch-http.ts @@ -1,5 +1,5 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { retryAsync } from "../infra/retry.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; +import { retryAsync } from "../../infra/retry.js"; import { postJson } from "./post-json.js"; export async function postJsonWithRetry(params: { diff --git a/src/memory/batch-openai.ts b/src/plugins/memory-host/batch-openai.ts similarity index 100% rename from src/memory/batch-openai.ts rename to src/plugins/memory-host/batch-openai.ts diff --git a/src/memory/batch-output.test.ts b/src/plugins/memory-host/batch-output.test.ts similarity index 100% rename from src/memory/batch-output.test.ts rename to src/plugins/memory-host/batch-output.test.ts diff --git a/src/memory/batch-output.ts b/src/plugins/memory-host/batch-output.ts similarity index 100% rename from src/memory/batch-output.ts rename to src/plugins/memory-host/batch-output.ts diff --git a/src/memory/batch-provider-common.ts b/src/plugins/memory-host/batch-provider-common.ts similarity index 100% rename from src/memory/batch-provider-common.ts rename to src/plugins/memory-host/batch-provider-common.ts diff --git a/src/memory/batch-runner.ts b/src/plugins/memory-host/batch-runner.ts similarity index 100% rename from src/memory/batch-runner.ts rename to src/plugins/memory-host/batch-runner.ts diff --git a/src/memory/batch-status.test.ts b/src/plugins/memory-host/batch-status.test.ts similarity index 100% rename from src/memory/batch-status.test.ts rename to src/plugins/memory-host/batch-status.test.ts diff --git a/src/memory/batch-status.ts b/src/plugins/memory-host/batch-status.ts similarity index 100% rename from src/memory/batch-status.ts rename to src/plugins/memory-host/batch-status.ts diff --git a/src/memory/batch-upload.ts b/src/plugins/memory-host/batch-upload.ts similarity index 100% rename from src/memory/batch-upload.ts rename to src/plugins/memory-host/batch-upload.ts diff --git a/src/memory/batch-utils.ts b/src/plugins/memory-host/batch-utils.ts similarity index 94% rename from src/memory/batch-utils.ts rename to src/plugins/memory-host/batch-utils.ts index c8f9249d9b8..c44dace3f8a 100644 --- a/src/memory/batch-utils.ts +++ b/src/plugins/memory-host/batch-utils.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; export type BatchHttpClientConfig = { baseUrl?: string; diff --git a/src/memory/batch-voyage.test.ts b/src/plugins/memory-host/batch-voyage.test.ts similarity index 100% rename from src/memory/batch-voyage.test.ts rename to src/plugins/memory-host/batch-voyage.test.ts diff --git a/src/memory/batch-voyage.ts b/src/plugins/memory-host/batch-voyage.ts similarity index 100% rename from src/memory/batch-voyage.ts rename to src/plugins/memory-host/batch-voyage.ts diff --git a/src/memory/embedding-chunk-limits.test.ts b/src/plugins/memory-host/embedding-chunk-limits.test.ts similarity index 100% rename from src/memory/embedding-chunk-limits.test.ts rename to src/plugins/memory-host/embedding-chunk-limits.test.ts diff --git a/src/memory/embedding-chunk-limits.ts b/src/plugins/memory-host/embedding-chunk-limits.ts similarity index 100% rename from src/memory/embedding-chunk-limits.ts rename to src/plugins/memory-host/embedding-chunk-limits.ts diff --git a/src/memory/embedding-input-limits.ts b/src/plugins/memory-host/embedding-input-limits.ts similarity index 100% rename from src/memory/embedding-input-limits.ts rename to src/plugins/memory-host/embedding-input-limits.ts diff --git a/src/memory/embedding-inputs.ts b/src/plugins/memory-host/embedding-inputs.ts similarity index 100% rename from src/memory/embedding-inputs.ts rename to src/plugins/memory-host/embedding-inputs.ts diff --git a/src/memory/embedding-model-limits.ts b/src/plugins/memory-host/embedding-model-limits.ts similarity index 100% rename from src/memory/embedding-model-limits.ts rename to src/plugins/memory-host/embedding-model-limits.ts diff --git a/src/memory/embedding-vectors.ts b/src/plugins/memory-host/embedding-vectors.ts similarity index 100% rename from src/memory/embedding-vectors.ts rename to src/plugins/memory-host/embedding-vectors.ts diff --git a/src/memory/embeddings-debug.ts b/src/plugins/memory-host/embeddings-debug.ts similarity index 75% rename from src/memory/embeddings-debug.ts rename to src/plugins/memory-host/embeddings-debug.ts index 951d88b6c09..a9f20d55e8a 100644 --- a/src/memory/embeddings-debug.ts +++ b/src/plugins/memory-host/embeddings-debug.ts @@ -1,5 +1,5 @@ -import { isTruthyEnvValue } from "../infra/env.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isTruthyEnvValue } from "../../infra/env.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS); const log = createSubsystemLogger("memory/embeddings"); diff --git a/src/memory/embeddings-gemini.test.ts b/src/plugins/memory-host/embeddings-gemini.test.ts similarity index 98% rename from src/memory/embeddings-gemini.test.ts rename to src/plugins/memory-host/embeddings-gemini.test.ts index 617b4d6ed95..d6b41c8629a 100644 --- a/src/memory/embeddings-gemini.test.ts +++ b/src/plugins/memory-host/embeddings-gemini.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as authModule from "../agents/model-auth.js"; +import * as authModule from "../../agents/model-auth.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; -vi.mock("../agents/model-auth.js", async () => { - const { createModelAuthMockModule } = await import("../test-utils/model-auth-mock.js"); +vi.mock("../../agents/model-auth.js", async () => { + const { createModelAuthMockModule } = await import("../../test-utils/model-auth-mock.js"); return createModelAuthMockModule(); }); diff --git a/src/memory/embeddings-gemini.ts b/src/plugins/memory-host/embeddings-gemini.ts similarity index 97% rename from src/memory/embeddings-gemini.ts rename to src/plugins/memory-host/embeddings-gemini.ts index b1df835fdac..3826398a371 100644 --- a/src/memory/embeddings-gemini.ts +++ b/src/plugins/memory-host/embeddings-gemini.ts @@ -1,14 +1,14 @@ import { collectProviderApiKeysForExecution, executeWithApiKeyRotation, -} from "../agents/api-key-rotation.js"; -import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; -import { parseGeminiAuth } from "../infra/gemini-auth.js"; +} from "../../agents/api-key-rotation.js"; +import { requireApiKey, resolveApiKeyForProvider } from "../../agents/model-auth.js"; +import { parseGeminiAuth } from "../../infra/gemini-auth.js"; import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl, -} from "../infra/google-api-base-url.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +} from "../../infra/google-api-base-url.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import type { EmbeddingInput } from "./embedding-inputs.js"; import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js"; import { debugEmbeddingsLog } from "./embeddings-debug.js"; diff --git a/src/memory/embeddings-mistral.test.ts b/src/plugins/memory-host/embeddings-mistral.test.ts similarity index 100% rename from src/memory/embeddings-mistral.test.ts rename to src/plugins/memory-host/embeddings-mistral.test.ts diff --git a/src/memory/embeddings-mistral.ts b/src/plugins/memory-host/embeddings-mistral.ts similarity index 96% rename from src/memory/embeddings-mistral.ts rename to src/plugins/memory-host/embeddings-mistral.ts index 0347c2b017c..90e9799414f 100644 --- a/src/memory/embeddings-mistral.ts +++ b/src/plugins/memory-host/embeddings-mistral.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js"; import { createRemoteEmbeddingProvider, diff --git a/src/memory/embeddings-model-normalize.test.ts b/src/plugins/memory-host/embeddings-model-normalize.test.ts similarity index 100% rename from src/memory/embeddings-model-normalize.test.ts rename to src/plugins/memory-host/embeddings-model-normalize.test.ts diff --git a/src/memory/embeddings-model-normalize.ts b/src/plugins/memory-host/embeddings-model-normalize.ts similarity index 100% rename from src/memory/embeddings-model-normalize.ts rename to src/plugins/memory-host/embeddings-model-normalize.ts diff --git a/src/memory/embeddings-ollama.test.ts b/src/plugins/memory-host/embeddings-ollama.test.ts similarity index 98% rename from src/memory/embeddings-ollama.test.ts rename to src/plugins/memory-host/embeddings-ollama.test.ts index 66e02e3c36b..6e425a7d1fd 100644 --- a/src/memory/embeddings-ollama.test.ts +++ b/src/plugins/memory-host/embeddings-ollama.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; let createOllamaEmbeddingProvider: typeof import("./embeddings-ollama.js").createOllamaEmbeddingProvider; diff --git a/src/memory/embeddings-ollama.ts b/src/plugins/memory-host/embeddings-ollama.ts similarity index 91% rename from src/memory/embeddings-ollama.ts rename to src/plugins/memory-host/embeddings-ollama.ts index 7bd2bcf7428..4c867d735a6 100644 --- a/src/memory/embeddings-ollama.ts +++ b/src/plugins/memory-host/embeddings-ollama.ts @@ -1,8 +1,8 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { resolveOllamaApiBase } from "../agents/ollama-models.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { resolveEnvApiKey } from "../../agents/model-auth.js"; +import { resolveOllamaApiBase } from "../../agents/ollama-models.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; +import { normalizeOptionalSecretInput } from "../../utils/normalize-secret-input.js"; import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js"; import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; diff --git a/src/memory/embeddings-openai.ts b/src/plugins/memory-host/embeddings-openai.ts similarity index 91% rename from src/memory/embeddings-openai.ts rename to src/plugins/memory-host/embeddings-openai.ts index eff9e8b27f2..867767acaf5 100644 --- a/src/memory/embeddings-openai.ts +++ b/src/plugins/memory-host/embeddings-openai.ts @@ -1,5 +1,5 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { OPENAI_DEFAULT_EMBEDDING_MODEL } from "../plugins/provider-model-defaults.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; +import { OPENAI_DEFAULT_EMBEDDING_MODEL } from "../../plugins/provider-model-defaults.js"; import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js"; import { createRemoteEmbeddingProvider, diff --git a/src/memory/embeddings-remote-client.ts b/src/plugins/memory-host/embeddings-remote-client.ts similarity index 91% rename from src/memory/embeddings-remote-client.ts rename to src/plugins/memory-host/embeddings-remote-client.ts index a471d5a75b0..154b886cdf2 100644 --- a/src/memory/embeddings-remote-client.ts +++ b/src/plugins/memory-host/embeddings-remote-client.ts @@ -1,5 +1,5 @@ -import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { requireApiKey, resolveApiKeyForProvider } from "../../agents/model-auth.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import type { EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy } from "./remote-http.js"; import { resolveMemorySecretInputString } from "./secret-input.js"; diff --git a/src/memory/embeddings-remote-fetch.test.ts b/src/plugins/memory-host/embeddings-remote-fetch.test.ts similarity index 100% rename from src/memory/embeddings-remote-fetch.test.ts rename to src/plugins/memory-host/embeddings-remote-fetch.test.ts diff --git a/src/memory/embeddings-remote-fetch.ts b/src/plugins/memory-host/embeddings-remote-fetch.ts similarity index 91% rename from src/memory/embeddings-remote-fetch.ts rename to src/plugins/memory-host/embeddings-remote-fetch.ts index 538806e8f9a..a45acb37456 100644 --- a/src/memory/embeddings-remote-fetch.ts +++ b/src/plugins/memory-host/embeddings-remote-fetch.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { postJson } from "./post-json.js"; export async function fetchRemoteEmbeddingVectors(params: { diff --git a/src/memory/embeddings-remote-provider.ts b/src/plugins/memory-host/embeddings-remote-provider.ts similarity index 96% rename from src/memory/embeddings-remote-provider.ts rename to src/plugins/memory-host/embeddings-remote-provider.ts index 0d191af57e9..c0c9a0cb2dd 100644 --- a/src/memory/embeddings-remote-provider.ts +++ b/src/plugins/memory-host/embeddings-remote-provider.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { resolveRemoteEmbeddingBearerClient, type RemoteEmbeddingProviderId, diff --git a/src/memory/embeddings-voyage.test.ts b/src/plugins/memory-host/embeddings-voyage.test.ts similarity index 93% rename from src/memory/embeddings-voyage.test.ts rename to src/plugins/memory-host/embeddings-voyage.test.ts index 2914b8b18a4..b54fdf6934f 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/plugins/memory-host/embeddings-voyage.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type FetchMock, withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; -vi.mock("../agents/model-auth.js", async () => { - const { createModelAuthMockModule } = await import("../test-utils/model-auth-mock.js"); +vi.mock("../../agents/model-auth.js", async () => { + const { createModelAuthMockModule } = await import("../../test-utils/model-auth-mock.js"); return createModelAuthMockModule(); }); @@ -18,7 +18,7 @@ const createFetchMock = () => { return withFetchPreconnect(fetchMock); }; -let authModule: typeof import("../agents/model-auth.js"); +let authModule: typeof import("../../agents/model-auth.js"); let createVoyageEmbeddingProvider: typeof import("./embeddings-voyage.js").createVoyageEmbeddingProvider; let normalizeVoyageModel: typeof import("./embeddings-voyage.js").normalizeVoyageModel; @@ -26,7 +26,7 @@ beforeEach(async () => { vi.useRealTimers(); vi.doUnmock("undici"); vi.resetModules(); - authModule = await import("../agents/model-auth.js"); + authModule = await import("../../agents/model-auth.js"); ({ createVoyageEmbeddingProvider, normalizeVoyageModel } = await import("./embeddings-voyage.js")); }); diff --git a/src/memory/embeddings-voyage.ts b/src/plugins/memory-host/embeddings-voyage.ts similarity index 97% rename from src/memory/embeddings-voyage.ts rename to src/plugins/memory-host/embeddings-voyage.ts index b078ebdb21a..caf4165d1f1 100644 --- a/src/memory/embeddings-voyage.ts +++ b/src/plugins/memory-host/embeddings-voyage.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js"; import { resolveRemoteEmbeddingBearerClient } from "./embeddings-remote-client.js"; import { fetchRemoteEmbeddingVectors } from "./embeddings-remote-fetch.js"; diff --git a/src/memory/embeddings.test.ts b/src/plugins/memory-host/embeddings.test.ts similarity index 99% rename from src/memory/embeddings.test.ts rename to src/plugins/memory-host/embeddings.test.ts index 7404ea154e3..ff504a51940 100644 --- a/src/memory/embeddings.test.ts +++ b/src/plugins/memory-host/embeddings.test.ts @@ -23,7 +23,7 @@ function readFirstFetchRequest(fetchMock: { mock: { calls: unknown[][] } }) { } type EmbeddingsModule = typeof import("./embeddings.js"); -type AuthModule = typeof import("../agents/model-auth.js"); +type AuthModule = typeof import("../../agents/model-auth.js"); type ResolvedProviderAuth = Awaited>; let authModule: AuthModule; @@ -33,7 +33,7 @@ let DEFAULT_LOCAL_MODEL: EmbeddingsModule["DEFAULT_LOCAL_MODEL"]; beforeEach(async () => { vi.resetModules(); - authModule = await import("../agents/model-auth.js"); + authModule = await import("../../agents/model-auth.js"); nodeLlamaModule = await import("./node-llama.js"); vi.spyOn(authModule, "resolveApiKeyForProvider"); vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp"); @@ -683,7 +683,7 @@ describe("local embedding ensureContext concurrency", () => { describe("FTS-only fallback when no provider available", () => { beforeEach(async () => { - authModule = await import("../agents/model-auth.js"); + authModule = await import("../../agents/model-auth.js"); ({ createEmbeddingProvider, DEFAULT_LOCAL_MODEL } = await import("./embeddings.js")); }); diff --git a/src/memory/embeddings.ts b/src/plugins/memory-host/embeddings.ts similarity index 97% rename from src/memory/embeddings.ts rename to src/plugins/memory-host/embeddings.ts index 23d63ad766a..ee18eb34c7f 100644 --- a/src/memory/embeddings.ts +++ b/src/plugins/memory-host/embeddings.ts @@ -1,9 +1,9 @@ import fsSync from "node:fs"; import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp"; -import type { OpenClawConfig } from "../config/config.js"; -import type { SecretInput } from "../config/types.secrets.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { resolveUserPath } from "../utils.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { SecretInput } from "../../config/types.secrets.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { resolveUserPath } from "../../utils.js"; import type { EmbeddingInput } from "./embedding-inputs.js"; import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js"; import { @@ -102,7 +102,7 @@ function isMissingApiKeyError(err: unknown): boolean { return message.includes("No API key found for provider"); } -async function createLocalEmbeddingProvider( +export async function createLocalEmbeddingProvider( options: EmbeddingProviderOptions, ): Promise { const modelPath = options.local?.modelPath?.trim() || DEFAULT_LOCAL_MODEL; diff --git a/src/memory/fs-utils.ts b/src/plugins/memory-host/fs-utils.ts similarity index 100% rename from src/memory/fs-utils.ts rename to src/plugins/memory-host/fs-utils.ts diff --git a/src/memory/internal.test.ts b/src/plugins/memory-host/internal.test.ts similarity index 100% rename from src/memory/internal.test.ts rename to src/plugins/memory-host/internal.test.ts diff --git a/src/memory/internal.ts b/src/plugins/memory-host/internal.ts similarity index 98% rename from src/memory/internal.ts rename to src/plugins/memory-host/internal.ts index d1d7e9c2e96..f5312ed30f2 100644 --- a/src/memory/internal.ts +++ b/src/plugins/memory-host/internal.ts @@ -2,8 +2,8 @@ import crypto from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { detectMime } from "../media/mime.js"; -import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; +import { detectMime } from "../../media/mime.js"; +import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js"; import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js"; import { buildTextEmbeddingInput, type EmbeddingInput } from "./embedding-inputs.js"; import { isFileMissingError } from "./fs-utils.js"; diff --git a/src/memory/memory-schema.ts b/src/plugins/memory-host/memory-schema.ts similarity index 100% rename from src/memory/memory-schema.ts rename to src/plugins/memory-host/memory-schema.ts diff --git a/src/memory/multimodal.ts b/src/plugins/memory-host/multimodal.ts similarity index 100% rename from src/memory/multimodal.ts rename to src/plugins/memory-host/multimodal.ts diff --git a/src/memory/node-llama.ts b/src/plugins/memory-host/node-llama.ts similarity index 100% rename from src/memory/node-llama.ts rename to src/plugins/memory-host/node-llama.ts diff --git a/src/memory/post-json.test.ts b/src/plugins/memory-host/post-json.test.ts similarity index 100% rename from src/memory/post-json.test.ts rename to src/plugins/memory-host/post-json.test.ts diff --git a/src/memory/post-json.ts b/src/plugins/memory-host/post-json.ts similarity index 93% rename from src/memory/post-json.ts rename to src/plugins/memory-host/post-json.ts index 5251fdab469..8eaee669cac 100644 --- a/src/memory/post-json.ts +++ b/src/plugins/memory-host/post-json.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { withRemoteHttpResponse } from "./remote-http.js"; export async function postJson(params: { diff --git a/src/memory/qmd-process.test.ts b/src/plugins/memory-host/qmd-process.test.ts similarity index 100% rename from src/memory/qmd-process.test.ts rename to src/plugins/memory-host/qmd-process.test.ts diff --git a/src/memory/qmd-process.ts b/src/plugins/memory-host/qmd-process.ts similarity index 98% rename from src/memory/qmd-process.ts rename to src/plugins/memory-host/qmd-process.ts index 5a70cd3c361..23754555033 100644 --- a/src/memory/qmd-process.ts +++ b/src/plugins/memory-host/qmd-process.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; +} from "../../plugin-sdk/windows-spawn.js"; export type CliSpawnInvocation = { command: string; diff --git a/src/memory/qmd-query-parser.test.ts b/src/plugins/memory-host/qmd-query-parser.test.ts similarity index 100% rename from src/memory/qmd-query-parser.test.ts rename to src/plugins/memory-host/qmd-query-parser.test.ts diff --git a/src/memory/qmd-query-parser.ts b/src/plugins/memory-host/qmd-query-parser.ts similarity index 98% rename from src/memory/qmd-query-parser.ts rename to src/plugins/memory-host/qmd-query-parser.ts index b5d602d6d40..32e8271b4f4 100644 --- a/src/memory/qmd-query-parser.ts +++ b/src/plugins/memory-host/qmd-query-parser.ts @@ -1,4 +1,4 @@ -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; const log = createSubsystemLogger("memory"); diff --git a/src/memory/qmd-scope.test.ts b/src/plugins/memory-host/qmd-scope.test.ts similarity index 100% rename from src/memory/qmd-scope.test.ts rename to src/plugins/memory-host/qmd-scope.test.ts diff --git a/src/memory/qmd-scope.ts b/src/plugins/memory-host/qmd-scope.ts similarity index 97% rename from src/memory/qmd-scope.ts rename to src/plugins/memory-host/qmd-scope.ts index ac28959db4a..a206cc9c2bd 100644 --- a/src/memory/qmd-scope.ts +++ b/src/plugins/memory-host/qmd-scope.ts @@ -1,4 +1,4 @@ -import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import type { ResolvedQmdConfig } from "./backend-config.js"; type ParsedQmdSessionScope = { diff --git a/src/memory/query-expansion.test.ts b/src/plugins/memory-host/query-expansion.test.ts similarity index 100% rename from src/memory/query-expansion.test.ts rename to src/plugins/memory-host/query-expansion.test.ts diff --git a/src/memory/query-expansion.ts b/src/plugins/memory-host/query-expansion.ts similarity index 100% rename from src/memory/query-expansion.ts rename to src/plugins/memory-host/query-expansion.ts diff --git a/src/memory/read-file.ts b/src/plugins/memory-host/read-file.ts similarity index 93% rename from src/memory/read-file.ts rename to src/plugins/memory-host/read-file.ts index 77ecf80932c..d9e6bbc3ce8 100644 --- a/src/memory/read-file.ts +++ b/src/plugins/memory-host/read-file.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import { resolveMemorySearchConfig } from "../agents/memory-search.js"; -import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { resolveMemorySearchConfig } from "../../agents/memory-search.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { isFileMissingError, statRegularFile } from "./fs-utils.js"; import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js"; diff --git a/src/memory/remote-http.ts b/src/plugins/memory-host/remote-http.ts similarity index 89% rename from src/memory/remote-http.ts rename to src/plugins/memory-host/remote-http.ts index 5a05dcdc40c..132e92a7548 100644 --- a/src/memory/remote-http.ts +++ b/src/plugins/memory-host/remote-http.ts @@ -1,5 +1,5 @@ -import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; export function buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined { const trimmed = baseUrl.trim(); diff --git a/src/memory/secret-input.ts b/src/plugins/memory-host/secret-input.ts similarity index 91% rename from src/memory/secret-input.ts rename to src/plugins/memory-host/secret-input.ts index 873870fc58a..98dd0c87084 100644 --- a/src/memory/secret-input.ts +++ b/src/plugins/memory-host/secret-input.ts @@ -1,7 +1,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, -} from "../config/types.secrets.js"; +} from "../../config/types.secrets.js"; export function hasConfiguredMemorySecretInput(value: unknown): boolean { return hasConfiguredSecretInput(value); diff --git a/src/memory/session-files.test.ts b/src/plugins/memory-host/session-files.test.ts similarity index 100% rename from src/memory/session-files.test.ts rename to src/plugins/memory-host/session-files.test.ts diff --git a/src/memory/session-files.ts b/src/plugins/memory-host/session-files.ts similarity index 94% rename from src/memory/session-files.ts rename to src/plugins/memory-host/session-files.ts index 285bdf409b1..291b0af418e 100644 --- a/src/memory/session-files.ts +++ b/src/plugins/memory-host/session-files.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; -import { redactSensitiveText } from "../logging/redact.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; +import { redactSensitiveText } from "../../logging/redact.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { hashText } from "./internal.js"; const log = createSubsystemLogger("memory"); diff --git a/src/memory/sqlite-vec.ts b/src/plugins/memory-host/sqlite-vec.ts similarity index 100% rename from src/memory/sqlite-vec.ts rename to src/plugins/memory-host/sqlite-vec.ts diff --git a/src/memory/sqlite.ts b/src/plugins/memory-host/sqlite.ts similarity index 89% rename from src/memory/sqlite.ts rename to src/plugins/memory-host/sqlite.ts index 3ff30061506..fabb16d983a 100644 --- a/src/memory/sqlite.ts +++ b/src/plugins/memory-host/sqlite.ts @@ -1,5 +1,5 @@ import { createRequire } from "node:module"; -import { installProcessWarningFilter } from "../infra/warning-filter.js"; +import { installProcessWarningFilter } from "../../infra/warning-filter.js"; const require = createRequire(import.meta.url); diff --git a/src/memory/status-format.ts b/src/plugins/memory-host/status-format.ts similarity index 100% rename from src/memory/status-format.ts rename to src/plugins/memory-host/status-format.ts diff --git a/src/memory/test-helpers/ssrf.ts b/src/plugins/memory-host/test-helpers/ssrf.ts similarity index 89% rename from src/memory/test-helpers/ssrf.ts rename to src/plugins/memory-host/test-helpers/ssrf.ts index c90ef0c4502..e8b6f99d553 100644 --- a/src/memory/test-helpers/ssrf.ts +++ b/src/plugins/memory-host/test-helpers/ssrf.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import * as ssrf from "../../infra/net/ssrf.js"; +import * as ssrf from "../../../infra/net/ssrf.js"; export function mockPublicPinnedHostname() { return vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => { diff --git a/src/memory/types.ts b/src/plugins/memory-host/types.ts similarity index 100% rename from src/memory/types.ts rename to src/plugins/memory-host/types.ts diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 9cb8c9d6f16..0fc49ab0007 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -4,7 +4,7 @@ import type { MemoryEmbeddingProbeResult, MemoryProviderStatus, MemorySyncProgressUpdate, -} from "../memory/types.js"; +} from "../plugins/memory-host/types.js"; export type MemoryPromptSectionBuilder = (params: { availableTools: Set; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 57a5bfce1b8..2fd4582e29f 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,6 +14,10 @@ import { registerPluginCommand, validatePluginCommandDefinition } from "./comman import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; +import { + getMemoryEmbeddingProvider, + registerMemoryEmbeddingProvider, +} from "./memory-embedding-providers.js"; import { registerMemoryFlushPlanResolver, registerMemoryPromptSection, @@ -1098,6 +1102,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } registerMemoryRuntime(runtime); }, + registerMemoryEmbeddingProvider: (adapter) => { + if (registrationMode !== "full") { + return; + } + const existing = getMemoryEmbeddingProvider(adapter.id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `memory embedding provider already registered: ${adapter.id}`, + }); + return; + } + registerMemoryEmbeddingProvider(adapter); + }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registrationMode === "full" diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 2cb8a2cd61f..84d11f2426e 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1435,6 +1435,10 @@ export type OpenClawPluginApi = { registerMemoryFlushPlan: (resolver: import("./memory-state.js").MemoryFlushPlanResolver) => void; /** Register the active memory runtime adapter for this memory plugin (exclusive slot). */ registerMemoryRuntime: (runtime: import("./memory-state.js").MemoryPluginRuntime) => void; + /** Register a memory embedding provider adapter. Multiple adapters may coexist. */ + registerMemoryEmbeddingProvider: ( + adapter: import("./memory-embedding-providers.js").MemoryEmbeddingProviderAdapter, + ) => void; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( diff --git a/test/helpers/extensions/plugin-api.ts b/test/helpers/extensions/plugin-api.ts index af7d5d2724d..329747698ec 100644 --- a/test/helpers/extensions/plugin-api.ts +++ b/test/helpers/extensions/plugin-api.ts @@ -27,6 +27,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerMemoryPromptSection() {}, registerMemoryFlushPlan() {}, registerMemoryRuntime() {}, + registerMemoryEmbeddingProvider() {}, resolvePath(input: string) { return input; }, diff --git a/test/helpers/memory-tool-manager-mock.ts b/test/helpers/memory-tool-manager-mock.ts index 367f98a5141..a4fd57c2664 100644 --- a/test/helpers/memory-tool-manager-mock.ts +++ b/test/helpers/memory-tool-manager-mock.ts @@ -38,11 +38,11 @@ const readAgentMemoryFileMock = vi.fn( async (params: MemoryReadParams) => await readFileImpl(params), ); -vi.mock("../../src/memory/index.js", () => ({ +vi.mock("../../extensions/memory-core/src/memory/index.js", () => ({ getMemorySearchManager: getMemorySearchManagerMock, })); -vi.mock("../../src/memory/read-file.js", () => ({ +vi.mock("../../src/plugins/memory-host/read-file.js", () => ({ readAgentMemoryFile: readAgentMemoryFileMock, }));