diff --git a/extensions/llama-cpp/index.test.ts b/extensions/llama-cpp/index.test.ts index 4ba2b6a0b404..37855eff2bf1 100644 --- a/extensions/llama-cpp/index.test.ts +++ b/extensions/llama-cpp/index.test.ts @@ -1,3 +1,5 @@ +import os from "node:os"; +import path from "node:path"; import { createPluginRegistryFixture, registerVirtualTestPlugin, @@ -21,7 +23,9 @@ import llamaCppPlugin from "./index.js"; import { DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, createLlamaCppEmbeddingProvider, + createLlamaCppMemoryEmbeddingProvider, formatLlamaCppSetupError, + llamaCppEmbeddingProviderAdapter, } from "./src/embedding-provider.js"; afterEach(() => { @@ -106,6 +110,285 @@ describe("llama.cpp provider plugin", () => { }); }); + it("keeps the default model identity when configured with its exact cache artifact path", async () => { + const modelPath = path.join( + os.homedir(), + ".node-llama-cpp", + "models", + "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + ); + memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({ + id: "local", + model: modelPath, + embedQuery: vi.fn(), + embedBatch: vi.fn(), + }); + + const result = await createLlamaCppMemoryEmbeddingProvider( + { + config: {}, + provider: "local", + fallback: "none", + model: modelPath, + local: { modelPath }, + }, + { nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" }, + ); + + expect(result.provider?.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL); + expect(result.runtime?.cacheKeyData).toEqual({ + provider: "local", + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + }); + expect(result.runtime?.indexIdentityAliases).toEqual([ + { + model: modelPath, + cacheKeyData: { + provider: "local", + model: modelPath, + }, + }, + { + model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + cacheKeyData: { + provider: "local", + model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + }, + }, + ]); + expect( + llamaCppEmbeddingProviderAdapter.resolveIndexIdentity?.({ + config: {}, + provider: "local", + model: modelPath, + local: { modelPath }, + }), + ).toEqual({ + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + cacheKeyData: { + provider: "local", + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + }, + aliases: [ + { + model: modelPath, + cacheKeyData: { + provider: "local", + model: modelPath, + }, + }, + { + model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + cacheKeyData: { + provider: "local", + model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + }, + }, + ], + }); + expect(memoryHostEmbeddingMocks.createLocalEmbeddingProvider).toHaveBeenCalledWith( + expect.objectContaining({ + model: modelPath, + local: { modelPath }, + }), + { + nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js", + }, + ); + }); + + it("keeps an arbitrary same-basename model path as a distinct identity", async () => { + const modelPath = path.join( + os.tmpdir(), + "custom-models", + DEFAULT_LLAMA_CPP_EMBEDDING_MODEL.split("/").at(-1)!, + ); + memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({ + id: "local", + model: modelPath, + embedQuery: vi.fn(), + embedBatch: vi.fn(), + }); + + const result = await createLlamaCppMemoryEmbeddingProvider( + { + config: {}, + provider: "local", + fallback: "none", + model: modelPath, + local: { modelPath }, + }, + { nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" }, + ); + + expect(result.provider?.model).toBe(modelPath); + expect(result.runtime?.cacheKeyData).toEqual({ + provider: "local", + model: modelPath, + }); + expect(result.runtime).not.toHaveProperty("indexIdentityAliases"); + }); + + it("keeps a bare same-basename file in the default cache as a distinct identity", async () => { + const modelPath = path.join( + os.homedir(), + ".node-llama-cpp", + "models", + DEFAULT_LLAMA_CPP_EMBEDDING_MODEL.split("/").at(-1)!, + ); + memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({ + id: "local", + model: modelPath, + embedQuery: vi.fn(), + embedBatch: vi.fn(), + }); + + const result = await createLlamaCppMemoryEmbeddingProvider( + { + config: {}, + provider: "local", + fallback: "none", + model: modelPath, + local: { modelPath }, + }, + { nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" }, + ); + + expect(result.provider?.model).toBe(modelPath); + expect(result.runtime).not.toHaveProperty("indexIdentityAliases"); + }); + + it("keeps the default model identity with a custom cache directory", async () => { + const modelCacheDir = path.join(os.tmpdir(), "llama-cpp-model-cache"); + const modelPath = path.join(modelCacheDir, "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf"); + memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({ + id: "local", + model: modelPath, + embedQuery: vi.fn(), + embedBatch: vi.fn(), + }); + + const result = await createLlamaCppMemoryEmbeddingProvider( + { + config: {}, + provider: "local", + fallback: "none", + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + local: { modelPath: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, modelCacheDir }, + }, + { nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" }, + ); + + expect(result.provider?.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL); + expect(result.runtime?.cacheKeyData).toEqual({ + provider: "local", + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + }); + expect(result.runtime?.indexIdentityAliases).toEqual([ + { + model: modelPath, + cacheKeyData: { + provider: "local", + model: modelPath, + }, + }, + { + model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + cacheKeyData: { + provider: "local", + model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + }, + }, + ]); + }); + + it.each([ + { + direction: "default URI to exact relative cache artifact", + modelPath: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + }, + { + direction: "exact relative cache artifact to default URI", + modelPath: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf", + }, + ])("keeps $direction compatible", ({ modelPath }) => { + const modelCacheDir = path.join(os.tmpdir(), "llama-cpp-relative-model-cache"); + const relativeModelPath = "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf"; + const resolvedModelPath = path.join(modelCacheDir, relativeModelPath); + + expect( + llamaCppEmbeddingProviderAdapter.resolveIndexIdentity?.({ + config: {}, + provider: "local", + model: modelPath, + local: { modelPath, modelCacheDir }, + }), + ).toEqual({ + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + cacheKeyData: { + provider: "local", + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + }, + aliases: [ + { + model: resolvedModelPath, + cacheKeyData: { + provider: "local", + model: resolvedModelPath, + }, + }, + { + model: relativeModelPath, + cacheKeyData: { + provider: "local", + model: relativeModelPath, + }, + }, + ], + }); + }); + + it("keeps the default model identity for its exact relative cache artifact", async () => { + const modelCacheDir = path.join(os.tmpdir(), "llama-cpp-relative-model-cache"); + const modelPath = "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf"; + const resolvedModelPath = path.join(modelCacheDir, modelPath); + memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({ + id: "local", + model: modelPath, + embedQuery: vi.fn(), + embedBatch: vi.fn(), + }); + + const result = await createLlamaCppMemoryEmbeddingProvider( + { + config: {}, + provider: "local", + fallback: "none", + model: modelPath, + local: { modelPath, modelCacheDir }, + }, + { nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" }, + ); + + expect(result.provider?.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL); + expect(result.runtime?.indexIdentityAliases).toEqual([ + { + model: resolvedModelPath, + cacheKeyData: { + provider: "local", + model: resolvedModelPath, + }, + }, + { + model: modelPath, + cacheKeyData: { + provider: "local", + model: modelPath, + }, + }, + ]); + }); + it("formats missing runtime errors with the plugin install command", () => { const err = Object.assign(new Error("Cannot find package 'node-llama-cpp'"), { code: "ERR_MODULE_NOT_FOUND", diff --git a/extensions/llama-cpp/src/embedding-provider.ts b/extensions/llama-cpp/src/embedding-provider.ts index c028f8404068..b0c2983d7230 100644 --- a/extensions/llama-cpp/src/embedding-provider.ts +++ b/extensions/llama-cpp/src/embedding-provider.ts @@ -1,10 +1,13 @@ import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; import { pathToFileURL } from "node:url"; import type { EmbeddingInput, EmbeddingProvider, EmbeddingProviderAdapter, EmbeddingProviderCreateOptions, + EmbeddingProviderCreateResult, } from "openclaw/plugin-sdk/embedding-providers"; import { createLocalEmbeddingProvider, @@ -27,6 +30,17 @@ export type LlamaCppEmbeddingProviderRuntimeOptions = { export const LLAMA_CPP_EMBEDDING_PROVIDER_ID = "local"; export const DEFAULT_LLAMA_CPP_EMBEDDING_MODEL = "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf"; +const DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME = + "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf"; + +type LlamaCppModelIdentity = { + model: string; + cacheKeyData: Record; + aliases: Array<{ + model: string; + cacheKeyData: Record; + }>; +}; function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; @@ -37,6 +51,56 @@ function readLocalOptions(options: { local?: unknown }): LlamaCppLocalOptions { return local ?? {}; } +function createLlamaCppCacheKeyData(model: string): Record { + return { + provider: LLAMA_CPP_EMBEDDING_PROVIDER_ID, + model, + }; +} + +function resolveLlamaCppModelIdentity( + local: LlamaCppLocalOptions, + modelPath: string, +): LlamaCppModelIdentity { + const modelCacheDir = + normalizeOptionalString(local.modelCacheDir) ?? + path.join(os.homedir(), ".node-llama-cpp", "models"); + const resolvedDefaultModelPath = path.resolve( + modelCacheDir, + DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME, + ); + const isModelUri = /^(?:hf:|https?:\/\/)/i.test(modelPath); + const resolvedModelPath = isModelUri ? undefined : path.resolve(modelCacheDir, modelPath); + // node-llama-cpp resolves the default HF URI to this exact cache target and + // accepts its URI-derived filename relative to any configured cache directory. + // Preserve that exact historical key; arbitrary filenames and paths stay distinct. + if ( + modelPath !== DEFAULT_LLAMA_CPP_EMBEDDING_MODEL && + resolvedModelPath !== resolvedDefaultModelPath + ) { + return { + model: modelPath, + cacheKeyData: createLlamaCppCacheKeyData(modelPath), + aliases: [], + }; + } + const aliasModels = new Set([ + resolvedDefaultModelPath, + DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME, + ]); + if (modelPath !== DEFAULT_LLAMA_CPP_EMBEDDING_MODEL) { + aliasModels.add(modelPath); + } + return { + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + cacheKeyData: createLlamaCppCacheKeyData(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL), + aliases: Array.from(aliasModels, (aliasModel) => ({ + model: aliasModel, + cacheKeyData: createLlamaCppCacheKeyData(aliasModel), + })), + }; +} + function textFromEmbeddingInput(input: EmbeddingInput): string { return typeof input === "string" ? input : input.text; } @@ -114,14 +178,11 @@ export async function createLlamaCppEmbeddingProvider( options: EmbeddingProviderCreateOptions, runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {}, ): Promise { - const result = await createLlamaCppMemoryEmbeddingProvider( - buildMemoryCreateOptions(options, options.dimensions), - runtimeOptions, - ); + const result = await createLlamaCppEmbeddingProviderResult(options, runtimeOptions); if (!result.provider) { throw new Error("llama.cpp local embedding provider was unavailable"); } - return adaptMemoryEmbeddingProvider(result.provider); + return result.provider; } export async function createLlamaCppMemoryEmbeddingProvider( @@ -129,12 +190,30 @@ export async function createLlamaCppMemoryEmbeddingProvider( runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {}, ): Promise { const createOptions = buildMemoryCreateOptions(options, options.outputDimensionality); + const local = readLocalOptions(createOptions); const provider = await createLocalEmbeddingProvider(createOptions, { nodeLlamaCppImportUrl: runtimeOptions.nodeLlamaCppImportUrl ?? resolveNodeLlamaCppImportUrl(), }); + const identity = resolveLlamaCppModelIdentity(local, provider.model); + const identifiedProvider = + identity.model === provider.model ? provider : { ...provider, model: identity.model }; return { - provider, - runtime: createLlamaCppEmbeddingProviderRuntime(provider), + provider: identifiedProvider, + runtime: createLlamaCppEmbeddingProviderRuntime(identity), + }; +} + +async function createLlamaCppEmbeddingProviderResult( + options: EmbeddingProviderCreateOptions, + runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {}, +): Promise { + const result = await createLlamaCppMemoryEmbeddingProvider( + buildMemoryCreateOptions(options, options.dimensions), + runtimeOptions, + ); + return { + provider: result.provider ? adaptMemoryEmbeddingProvider(result.provider) : null, + runtime: result.runtime, }; } @@ -162,15 +241,13 @@ function buildMemoryCreateOptions( }; } -function createLlamaCppEmbeddingProviderRuntime(provider: { model: string }) { +function createLlamaCppEmbeddingProviderRuntime(identity: LlamaCppModelIdentity) { return { id: LLAMA_CPP_EMBEDDING_PROVIDER_ID, inlineQueryTimeoutMs: 5 * 60_000, inlineBatchTimeoutMs: 10 * 60_000, - cacheKeyData: { - provider: LLAMA_CPP_EMBEDDING_PROVIDER_ID, - model: provider.model, - }, + cacheKeyData: identity.cacheKeyData, + ...(identity.aliases.length > 0 ? { indexIdentityAliases: identity.aliases } : {}), }; } @@ -179,11 +256,13 @@ export const llamaCppEmbeddingProviderAdapter: EmbeddingProviderAdapter = { defaultModel: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, transport: "local", formatSetupError: formatLlamaCppSetupError, - create: async (options) => { - const provider = await createLlamaCppEmbeddingProvider(options); - return { - provider, - runtime: createLlamaCppEmbeddingProviderRuntime(provider), - }; + resolveIndexIdentity: (options) => { + const createOptions = buildMemoryCreateOptions(options, options.dimensions); + const local = readLocalOptions(createOptions); + return resolveLlamaCppModelIdentity( + local, + normalizeOptionalString(local.modelPath) ?? DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + ); }, + create: async (options) => await createLlamaCppEmbeddingProviderResult(options), }; diff --git a/extensions/memory-core/src/memory/embedding.test-mocks.ts b/extensions/memory-core/src/memory/embedding.test-mocks.ts index e0db3d6a957d..c91e65d37c47 100644 --- a/extensions/memory-core/src/memory/embedding.test-mocks.ts +++ b/extensions/memory-core/src/memory/embedding.test-mocks.ts @@ -30,6 +30,7 @@ vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, resolveEmbeddingProviderAdapterTransport: (providerId: string) => providerId === "local" ? "local" : "remote", + resolveEmbeddingProviderIndexIdentity: () => undefined, createEmbeddingProvider: async () => ({ requestedProvider: "openai", provider: { diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index d9b7f836990e..52b52bb4d1b4 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -85,6 +85,9 @@ function adaptGenericRuntime( return { id: runtime.id, ...(runtime.cacheKeyData ? { cacheKeyData: runtime.cacheKeyData } : {}), + ...(runtime.indexIdentityAliases?.length + ? { indexIdentityAliases: runtime.indexIdentityAliases } + : {}), ...(typeof runtime.inlineQueryTimeoutMs === "number" ? { inlineQueryTimeoutMs: runtime.inlineQueryTimeoutMs } : {}), @@ -97,12 +100,24 @@ function adaptGenericRuntime( function adaptGenericEmbeddingAdapter( adapter: EmbeddingProviderAdapter, ): MemoryEmbeddingProviderAdapter { + const resolveIndexIdentity = adapter.resolveIndexIdentity; return { id: adapter.id, ...(adapter.defaultModel ? { defaultModel: adapter.defaultModel } : {}), ...(adapter.transport ? { transport: adapter.transport } : {}), ...(adapter.authProviderId ? { authProviderId: adapter.authProviderId } : {}), ...(adapter.formatSetupError ? { formatSetupError: adapter.formatSetupError } : {}), + ...(resolveIndexIdentity + ? { + resolveIndexIdentity: (options: MemoryEmbeddingProviderCreateOptions) => + resolveIndexIdentity({ + ...options, + ...(typeof options.outputDimensionality === "number" + ? { dimensions: options.outputDimensionality } + : {}), + }), + } + : {}), create: async (options) => { const result = await adapter.create({ ...options, @@ -184,6 +199,29 @@ export function resolveEmbeddingProviderAdapterTransport( } } +export function resolveEmbeddingProviderIndexIdentity(options: CreateEmbeddingProviderOptions) { + const provider = + options.provider === "auto" ? DEFAULT_MEMORY_EMBEDDING_PROVIDER : options.provider; + try { + const adapter = getAdapter(provider, options.config); + const model = resolveProviderModel(adapter, options.model); + const identity = adapter.resolveIndexIdentity?.({ + ...options, + provider, + model, + }); + return identity + ? { + provider: { id: adapter.id, model: identity.model }, + cacheKeyData: identity.cacheKeyData, + aliases: identity.aliases, + } + : undefined; + } catch { + return undefined; + } +} + async function createWithAdapter( adapter: MemoryEmbeddingProviderAdapter, options: CreateEmbeddingProviderOptions, diff --git a/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts b/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts index 6728f4ffbb57..af626ee1a03d 100644 --- a/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts +++ b/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts @@ -20,7 +20,7 @@ import { restoreRegisteredMemoryEmbeddingProviders, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createEmbeddingProvider } from "./embeddings.js"; +import { createEmbeddingProvider, resolveEmbeddingProviderIndexIdentity } from "./embeddings.js"; type CapturedCall = { kind: "embed" | "embedBatch"; @@ -76,6 +76,24 @@ describe("memory-core generic embedding provider bridge", () => { id: "virtual-generic", transport: "remote", defaultModel: "virtual-default", + resolveIndexIdentity: (options) => ({ + model: options.model, + cacheKeyData: { + provider: "virtual-generic", + model: options.model, + dimensions: options.dimensions, + }, + aliases: [ + { + model: "virtual-model-legacy", + cacheKeyData: { + provider: "virtual-generic", + model: "virtual-model-legacy", + dimensions: options.dimensions, + }, + }, + ], + }), create: async (options) => { expect(options.model).toBe("virtual-model"); expect(options.dimensions).toBe(7); @@ -106,6 +124,16 @@ describe("memory-core generic embedding provider bridge", () => { model: options.model, dimensions: options.dimensions, }, + indexIdentityAliases: [ + { + model: "virtual-model-legacy", + cacheKeyData: { + provider: "virtual-generic", + model: "virtual-model-legacy", + dimensions: options.dimensions, + }, + }, + ], }, }; }, @@ -121,6 +149,25 @@ describe("memory-core generic embedding provider bridge", () => { ]); expect(listRegisteredMemoryEmbeddingProviders()).toEqual([]); + expect(resolveEmbeddingProviderIndexIdentity(createOptions(config))).toEqual({ + provider: { id: "virtual-generic", model: "virtual-model" }, + cacheKeyData: { + provider: "virtual-generic", + model: "virtual-model", + dimensions: 7, + }, + aliases: [ + { + model: "virtual-model-legacy", + cacheKeyData: { + provider: "virtual-generic", + model: "virtual-model-legacy", + dimensions: 7, + }, + }, + ], + }); + const result = await createEmbeddingProvider(createOptions(config)); expect(result.requestedProvider).toBe("virtual-generic"); @@ -138,6 +185,16 @@ describe("memory-core generic embedding provider bridge", () => { model: "virtual-model", dimensions: 7, }, + indexIdentityAliases: [ + { + model: "virtual-model-legacy", + cacheKeyData: { + provider: "virtual-generic", + model: "virtual-model-legacy", + dimensions: 7, + }, + }, + ], }); await expect(result.provider?.embedQuery("query")).resolves.toEqual([1, 2, 3]); diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 7d6c9170d3ee..3760f44aea5f 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -8,6 +8,7 @@ import { listRegisteredMemoryEmbeddingProviderAdapters as listRegisteredAdapters, registerMemoryEmbeddingProvider as registerAdapter, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-runtime-mocks.js"; @@ -41,6 +42,12 @@ let providerInitGate: Promise | null = null; let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = []; let forceNoProvider = false; +const identityAliasFixture = vi.hoisted(() => ({ + provider: "identity-alias-test", + canonicalModel: "hf:fixture/default-model.gguf", + cacheModel: "/fixture/cache/default-model.gguf", +})); + function createLocalWorkerExitError(): Error { return Object.assign(new Error("Local embedding worker exited unexpectedly (exit code 134)"), { code: LOCAL_EMBEDDING_WORKER_ERROR_CODES.exited, @@ -73,6 +80,28 @@ vi.mock("./embeddings.js", () => { ) => config?.models?.providers?.[providerId]?.api ?? providerId, resolveEmbeddingProviderAdapterTransport: (providerId: string) => providerId === "local" ? "local" : "remote", + resolveEmbeddingProviderIndexIdentity: (options: { provider?: string; model?: string }) => + options.provider === identityAliasFixture.provider + ? { + provider: { + id: identityAliasFixture.provider, + model: identityAliasFixture.canonicalModel, + }, + cacheKeyData: { + provider: identityAliasFixture.provider, + model: identityAliasFixture.canonicalModel, + }, + aliases: [ + { + model: identityAliasFixture.cacheModel, + cacheKeyData: { + provider: identityAliasFixture.provider, + model: identityAliasFixture.cacheModel, + }, + }, + ], + } + : undefined, createEmbeddingProvider: async (options: { provider?: string; model?: string; @@ -96,10 +125,17 @@ vi.mock("./embeddings.js", () => { options.provider === "fallback-provider" || options.provider === "batch-test" || options.provider === "batch-wide-test" || + options.provider === identityAliasFixture.provider || options.provider === "ollama" ? options.provider : "mock"; - const model = options.model ?? "mock-embed"; + const requestedModel = options.model ?? "mock-embed"; + const model = + providerId === identityAliasFixture.provider && + (requestedModel === identityAliasFixture.canonicalModel || + requestedModel === identityAliasFixture.cacheModel) + ? identityAliasFixture.canonicalModel + : requestedModel; return { requestedProvider: options.provider ?? "openai", provider: { @@ -149,45 +185,64 @@ vi.mock("./embeddings.js", () => { } : {}), }, - ...(providerId === "batch-test" || providerId === "batch-wide-test" + ...(providerId === identityAliasFixture.provider ? { runtime: { id: providerId, - ...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}), - batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => { - providerRuntimeActiveBatchCalls += 1; - providerRuntimeMaxActiveBatchCalls = Math.max( - providerRuntimeMaxActiveBatchCalls, - providerRuntimeActiveBatchCalls, - ); - try { - await providerRuntimeBatchGate; - providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text)); - if (providerRuntimeBatchFailuresRemaining > 0) { - providerRuntimeBatchFailuresRemaining -= 1; - throw new Error("provider runtime batch failed"); - } - return batch.chunks.map((chunk) => embedText(chunk.text)); - } finally { - providerRuntimeActiveBatchCalls -= 1; - } + cacheKeyData: { + provider: providerId, + model: identityAliasFixture.canonicalModel, }, + indexIdentityAliases: [ + { + model: identityAliasFixture.cacheModel, + cacheKeyData: { + provider: providerId, + model: identityAliasFixture.cacheModel, + }, + }, + ], }, } - : providerId === "gemini" || providerId === "fallback-provider" + : providerId === "batch-test" || providerId === "batch-wide-test" ? { runtime: { id: providerId, - cacheKeyData: { - provider: providerId, - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - model, - outputDimensionality: options.outputDimensionality, - headers: [], + ...(providerId === "batch-wide-test" ? { sourceWideBatchEmbed: true } : {}), + batchEmbed: async (batch: { chunks: Array<{ text: string }> }) => { + providerRuntimeActiveBatchCalls += 1; + providerRuntimeMaxActiveBatchCalls = Math.max( + providerRuntimeMaxActiveBatchCalls, + providerRuntimeActiveBatchCalls, + ); + try { + await providerRuntimeBatchGate; + providerRuntimeBatchCalls.push(batch.chunks.map((chunk) => chunk.text)); + if (providerRuntimeBatchFailuresRemaining > 0) { + providerRuntimeBatchFailuresRemaining -= 1; + throw new Error("provider runtime batch failed"); + } + return batch.chunks.map((chunk) => embedText(chunk.text)); + } finally { + providerRuntimeActiveBatchCalls -= 1; + } }, }, } - : {}), + : providerId === "gemini" || providerId === "fallback-provider" + ? { + runtime: { + id: providerId, + cacheKeyData: { + provider: providerId, + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + model, + outputDimensionality: options.outputDimensionality, + headers: [], + }, + }, + } + : {}), }; }, }; @@ -382,6 +437,33 @@ describe("memory index", () => { return await getRequiredMemoryIndexManager({ cfg, agentId: "main", purpose }); } + function rewritePersistedProviderIdentity(manager: MemoryIndexManager, model: string): void { + const providerKey = hashText( + JSON.stringify({ + provider: identityAliasFixture.provider, + model, + }), + ); + const db = Reflect.get(manager, "db") as { + prepare: (sql: string) => { + get: (...params: unknown[]) => { value?: string } | undefined; + run: (...params: unknown[]) => void; + }; + }; + const metaRow = db.prepare("SELECT value FROM meta WHERE key = ?").get("memory_index_meta_v1"); + const meta = JSON.parse(metaRow?.value ?? "{}") as MemoryIndexMeta; + db.prepare("UPDATE meta SET value = ? WHERE key = ?").run( + JSON.stringify({ ...meta, model, providerKey }), + "memory_index_meta_v1", + ); + db.prepare("UPDATE chunks SET model = ?").run(model); + db.prepare("UPDATE embedding_cache SET model = ?, provider_key = ? WHERE provider = ?").run( + model, + providerKey, + identityAliasFixture.provider, + ); + } + async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) { const manager = await getFreshManager(cfg); try { @@ -752,6 +834,75 @@ describe("memory index", () => { } }); + it.each([ + { + direction: "HF to exact cache path", + indexedModel: identityAliasFixture.canonicalModel, + configuredModel: identityAliasFixture.cacheModel, + }, + { + direction: "exact cache path to HF", + indexedModel: identityAliasFixture.cacheModel, + configuredModel: identityAliasFixture.canonicalModel, + }, + ])( + "keeps $direction indexes and embedding caches usable", + async ({ indexedModel, configuredModel }) => { + const dbPath = path.join( + workspaceDir, + `index-provider-identity-alias-${configuredModel === identityAliasFixture.canonicalModel ? "hf" : "path"}.sqlite`, + ); + const indexedCfg = createCfg({ + storePath: dbPath, + provider: identityAliasFixture.provider, + model: identityAliasFixture.canonicalModel, + cacheEnabled: true, + vectorEnabled: false, + onSearch: false, + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const indexedManager = await getFreshManager(indexedCfg); + await indexedManager.sync({ reason: "test", force: true }); + if (indexedModel !== identityAliasFixture.canonicalModel) { + rewritePersistedProviderIdentity(indexedManager, indexedModel); + } + await indexedManager.close?.(); + + const embedsBeforeReuse = embedBatchCalls; + const nextCfg = createCfg({ + storePath: dbPath, + provider: identityAliasFixture.provider, + model: configuredModel, + cacheEnabled: true, + vectorEnabled: false, + onSearch: false, + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const statusManager = await getFreshManager(nextCfg, "status"); + try { + expect(statusManager.status().dirty).toBe(false); + expect(statusManager.status().custom?.indexIdentity).toEqual({ status: "valid" }); + } finally { + await statusManager.close?.(); + } + + const nextManager = await getFreshManager(nextCfg); + try { + const results = await nextManager.search("zebra"); + + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.path).toContain("memory/2026-01-12.md"); + expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" }); + + await nextManager.sync({ reason: "test", force: true }); + + expect(embedBatchCalls).toBe(embedsBeforeReuse); + } finally { + await nextManager.close?.(); + } + }, + ); + it("keeps status clean when configured provider alias resolves to indexed adapter", async () => { const dbPath = path.join(workspaceDir, "index-provider-alias-status.sqlite"); const oldCfg = createCfg({ diff --git a/extensions/memory-core/src/memory/manager-embedding-cache.test.ts b/extensions/memory-core/src/memory/manager-embedding-cache.test.ts index 77d170791ff0..1422f3d74c4c 100644 --- a/extensions/memory-core/src/memory/manager-embedding-cache.test.ts +++ b/extensions/memory-core/src/memory/manager-embedding-cache.test.ts @@ -44,8 +44,13 @@ describe("memory embedding cache", () => { const cached = loadMemoryEmbeddingCache({ db, enabled: true, - provider: { id: "openai", model: "text-embedding-3-small" }, - providerKey: "provider-key", + providerIdentities: [ + { + provider: "openai", + model: "text-embedding-3-small", + providerKey: "provider-key", + }, + ], hashes: ["a", "b", "a"], }); @@ -60,6 +65,48 @@ describe("memory embedding cache", () => { } }); + it("loads provider-declared alias cache rows without accepting arbitrary identities", () => { + const db = createDb(); + try { + upsertMemoryEmbeddingCache({ + db, + enabled: true, + provider: { id: "local", model: "/cache/default.gguf" }, + providerKey: "provider-key-alias", + entries: [{ hash: "alias", embedding: [0.1, 0.2] }], + }); + upsertMemoryEmbeddingCache({ + db, + enabled: true, + provider: { id: "local", model: "/other/default.gguf" }, + providerKey: "provider-key-arbitrary", + entries: [{ hash: "arbitrary", embedding: [0.3, 0.4] }], + }); + + const cached = loadMemoryEmbeddingCache({ + db, + enabled: true, + providerIdentities: [ + { + provider: "local", + model: "hf:owner/default.gguf", + providerKey: "provider-key-current", + }, + { + provider: "local", + model: "/cache/default.gguf", + providerKey: "provider-key-alias", + }, + ], + hashes: ["alias", "arbitrary"], + }); + + expect(cached).toEqual(new Map([["alias", [0.1, 0.2]]])); + } finally { + db.close(); + } + }); + it("reuses cached embeddings on forced reindex instead of scheduling new embeds", () => { const cached = new Map([ ["alpha", [0.1, 0.2]], diff --git a/extensions/memory-core/src/memory/manager-embedding-cache.ts b/extensions/memory-core/src/memory/manager-embedding-cache.ts index 0eb4d06cb27f..c98f76ab984f 100644 --- a/extensions/memory-core/src/memory/manager-embedding-cache.ts +++ b/extensions/memory-core/src/memory/manager-embedding-cache.ts @@ -7,21 +7,20 @@ import { type EmbeddingCacheDb = Pick; -type EmbeddingProviderRef = { - id: string; +type EmbeddingProviderIdentity = { + provider: string; model: string; + providerKey: string; }; export function loadMemoryEmbeddingCache(params: { db: EmbeddingCacheDb; enabled: boolean; - provider: EmbeddingProviderRef | null; - providerKey: string | null; + providerIdentities: EmbeddingProviderIdentity[]; hashes: string[]; tableName?: string; }): Map { - const provider = params.provider; - if (!params.enabled || !provider || !params.providerKey || params.hashes.length === 0) { + if (!params.enabled || params.providerIdentities.length === 0 || params.hashes.length === 0) { return new Map(); } const unique: string[] = []; @@ -39,19 +38,23 @@ export function loadMemoryEmbeddingCache(params: { const tableName = params.tableName ?? "embedding_cache"; const out = new Map(); - const baseParams: SQLInputValue[] = [provider.id, provider.model, params.providerKey]; const batchSize = 400; - for (let start = 0; start < unique.length; start += batchSize) { - const batch = unique.slice(start, start + batchSize); - const placeholders = batch.map(() => "?").join(", "); - const rows = params.db - .prepare( - `SELECT hash, embedding FROM ${tableName}\n` + - ` WHERE provider = ? AND model = ? AND provider_key = ? AND hash IN (${placeholders})`, - ) - .all(...baseParams, ...batch) as Array<{ hash: string; embedding: string }>; - for (const row of rows) { - out.set(row.hash, parseEmbedding(row.embedding)); + for (const identity of params.providerIdentities) { + const baseParams: SQLInputValue[] = [identity.provider, identity.model, identity.providerKey]; + for (let start = 0; start < unique.length; start += batchSize) { + const batch = unique.slice(start, start + batchSize); + const placeholders = batch.map(() => "?").join(", "); + const rows = params.db + .prepare( + `SELECT hash, embedding FROM ${tableName}\n` + + ` WHERE provider = ? AND model = ? AND provider_key = ? AND hash IN (${placeholders})`, + ) + .all(...baseParams, ...batch) as Array<{ hash: string; embedding: string }>; + for (const row of rows) { + if (!out.has(row.hash)) { + out.set(row.hash, parseEmbedding(row.embedding)); + } + } } } return out; @@ -60,7 +63,7 @@ export function loadMemoryEmbeddingCache(params: { export function upsertMemoryEmbeddingCache(params: { db: EmbeddingCacheDb; enabled: boolean; - provider: EmbeddingProviderRef | null; + provider: { id: string; model: string } | null; providerKey: string | null; entries: Array<{ hash: string; embedding: number[] }>; now?: number; diff --git a/extensions/memory-core/src/memory/manager-embedding-ops.ts b/extensions/memory-core/src/memory/manager-embedding-ops.ts index e36439dee468..50bbfeed8200 100644 --- a/extensions/memory-core/src/memory/manager-embedding-ops.ts +++ b/extensions/memory-core/src/memory/manager-embedding-ops.ts @@ -41,6 +41,10 @@ import { runMemoryEmbeddingRetryLoop, } from "./manager-embedding-policy.js"; import { deleteMemoryFtsRows } from "./manager-fts-state.js"; +import { + resolveMemoryIndexProviderIdentities, + type MemoryIndexProviderIdentity, +} from "./manager-reindex-state.js"; import { MemoryManagerSyncOps, type MemoryIndexWorkItem } from "./manager-sync-ops.js"; import { logMemoryVectorDegradedWrite } from "./manager-vector-warning.js"; import { replaceMemoryVectorRow } from "./manager-vector-write.js"; @@ -306,14 +310,15 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { } protected computeProviderKey(): string { - // FTS-only mode: no provider, use a constant key - if (!this.provider) { - return hashText(JSON.stringify({ provider: "none", model: "fts-only" })); - } - if (this.providerRuntime?.cacheKeyData) { - return hashText(JSON.stringify(this.providerRuntime.cacheKeyData)); - } - return hashText(JSON.stringify({ provider: this.provider.id, model: this.provider.model })); + return this.resolveProviderIndexIdentities()[0]!.providerKey; + } + + protected resolveProviderIndexIdentities(): MemoryIndexProviderIdentity[] { + return resolveMemoryIndexProviderIdentities({ + provider: this.provider, + cacheKeyData: this.providerRuntime?.cacheKeyData, + aliases: this.providerRuntime?.indexIdentityAliases, + }); } private buildBatchDebug( @@ -390,8 +395,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { cached: loadMemoryEmbeddingCache({ db: this.db, enabled: this.cache.enabled, - provider: this.provider, - providerKey: this.providerKey, + providerIdentities: this.provider ? this.resolveProviderIndexIdentities() : [], hashes: chunks.map((chunk) => chunk.hash), tableName: EMBEDDING_CACHE_TABLE, }), diff --git a/extensions/memory-core/src/memory/manager-reindex-state.test.ts b/extensions/memory-core/src/memory/manager-reindex-state.test.ts index e7ca7e84ec45..38de283a7d48 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.test.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { resolveConfiguredScopeHash, resolveConfiguredSourcesForMeta, + resolveMemoryIndexProviderIdentities, resolveMemoryIndexIdentityState, isMemoryIndexIdentityDirty, type MemoryIndexMeta, @@ -28,6 +29,7 @@ function createIdentityParams( meta?: MemoryIndexMeta | null; provider?: { id: string; model: string } | null; providerKey?: string; + providerAliases?: Array<{ model: string; providerKey: string }>; providerKeyKnown?: boolean; configuredSources?: MemorySource[]; configuredScopeHash?: string; @@ -54,6 +56,14 @@ function createIdentityParams( } describe("memory reindex state", () => { + it("retains the primary provider identity when its model is empty", () => { + expect( + resolveMemoryIndexProviderIdentities({ + provider: { id: "empty-model-provider", model: "" }, + }), + ).toMatchObject([{ provider: "empty-model-provider", model: "" }]); + }); + it("marks identity dirty when the embedding model changes", () => { expect( isMemoryIndexIdentityDirty( @@ -105,6 +115,70 @@ describe("memory reindex state", () => { ).toEqual({ status: "valid" }); }); + it("keeps model identity strict when paths share a basename", () => { + const indexedModel = "/models/default/model.gguf"; + const currentModel = "/models/custom/model.gguf"; + + expect( + resolveMemoryIndexIdentityState( + createIdentityParams({ + provider: { id: "local", model: currentModel }, + providerKey: "provider-key-current", + meta: createMeta({ + provider: "local", + model: indexedModel, + providerKey: "provider-key-indexed", + vectorDims: 768, + }), + vectorReady: true, + }), + ), + ).toEqual({ + status: "mismatched", + reason: `index was built for model ${indexedModel}, expected ${currentModel}`, + }); + }); + + it("accepts only provider-declared model and provider-key alias pairs", () => { + const alias = { + model: "/models/default/model.gguf", + providerKey: "provider-key-alias", + }; + + expect( + resolveMemoryIndexIdentityState( + createIdentityParams({ + provider: { id: "local", model: "hf:owner/default/model.gguf" }, + providerKey: "provider-key-current", + providerAliases: [alias], + meta: createMeta({ + provider: "local", + model: alias.model, + providerKey: alias.providerKey, + }), + }), + ), + ).toEqual({ status: "valid" }); + + expect( + resolveMemoryIndexIdentityState( + createIdentityParams({ + provider: { id: "local", model: "hf:owner/default/model.gguf" }, + providerKey: "provider-key-current", + providerAliases: [alias], + meta: createMeta({ + provider: "local", + model: alias.model, + providerKey: "provider-key-arbitrary", + }), + }), + ), + ).toEqual({ + status: "mismatched", + reason: "index provider settings changed", + }); + }); + it("does not mark identity dirty for vector dimensions before chunks exist", () => { expect( resolveMemoryIndexIdentityState( diff --git a/extensions/memory-core/src/memory/manager-reindex-state.ts b/extensions/memory-core/src/memory/manager-reindex-state.ts index f798db663377..96a0306c73a4 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.ts @@ -30,6 +30,43 @@ export type MemoryIndexIdentityState = reason: string; }; +export type MemoryIndexProviderIdentity = { + provider: string; + model: string; + providerKey: string; +}; + +export function resolveMemoryIndexProviderIdentities(params: { + provider: { id: string; model: string } | null; + cacheKeyData?: Record; + aliases?: Array<{ model: string; cacheKeyData: Record }>; +}): MemoryIndexProviderIdentity[] { + const provider = params.provider ?? { id: "none", model: "fts-only" }; + const candidates = [ + { + model: provider.model, + cacheKeyData: params.cacheKeyData ?? { provider: provider.id, model: provider.model }, + }, + ...(params.provider ? (params.aliases ?? []) : []), + ]; + const seen = new Set(); + const identities: MemoryIndexProviderIdentity[] = []; + for (const [index, candidate] of candidates.entries()) { + const providerKey = hashText(JSON.stringify(candidate.cacheKeyData)); + const key = `${candidate.model}\u0000${providerKey}`; + if ((index > 0 && !candidate.model) || seen.has(key)) { + continue; + } + seen.add(key); + identities.push({ + provider: provider.id, + model: candidate.model, + providerKey, + }); + } + return identities; +} + export function resolveConfiguredSourcesForMeta(sources: Iterable): MemorySource[] { const normalized = Array.from(sources) .filter((source): source is MemorySource => source === "memory" || source === "sessions") @@ -91,6 +128,7 @@ export function isMemoryIndexIdentityDirty(params: { meta: MemoryIndexMeta | null; provider: { id: string; model: string } | null; providerKey?: string; + providerAliases?: Array>; providerKeyKnown?: boolean; configuredSources: MemorySource[]; configuredScopeHash: string; @@ -107,6 +145,7 @@ export function resolveMemoryIndexIdentityState(params: { meta: MemoryIndexMeta | null; provider: { id: string; model: string } | null; providerKey?: string; + providerAliases?: Array>; providerKeyKnown?: boolean; configuredSources: MemorySource[]; configuredScopeHash: string; @@ -121,7 +160,11 @@ export function resolveMemoryIndexIdentityState(params: { return { status: "missing", reason: "index metadata is missing" }; } const expectedModel = params.provider ? params.provider.model : "fts-only"; - if (meta.model !== expectedModel) { + const matchingModelIdentities = [ + { model: expectedModel, providerKey: params.providerKey }, + ...(params.providerAliases ?? []), + ].filter((identity) => identity.model === meta.model); + if (matchingModelIdentities.length === 0) { return { status: "mismatched", reason: `index was built for model ${meta.model}, expected ${expectedModel}`, @@ -134,7 +177,10 @@ export function resolveMemoryIndexIdentityState(params: { reason: `index was built for provider ${meta.provider}, expected ${expectedProvider}`, }; } - if (params.providerKeyKnown !== false && meta.providerKey !== params.providerKey) { + if ( + params.providerKeyKnown !== false && + !matchingModelIdentities.some((identity) => identity.providerKey === meta.providerKey) + ) { return { status: "mismatched", reason: "index provider settings changed", diff --git a/extensions/memory-core/src/memory/manager-search.test.ts b/extensions/memory-core/src/memory/manager-search.test.ts index 2bde6136aac3..70181d515f3a 100644 --- a/extensions/memory-core/src/memory/manager-search.test.ts +++ b/extensions/memory-core/src/memory/manager-search.test.ts @@ -790,6 +790,56 @@ describe("searchVector sqlite-vec KNN", () => { } }); + it("searches provider-declared model aliases while excluding arbitrary paths", async () => { + const db = createFallbackDb(); + try { + insertFallbackChunk(db, { id: "canonical", model: "canonical-model", vector: [1, 0] }); + insertFallbackChunk(db, { id: "alias", model: "/cache/default.gguf", vector: [0.9, 0.1] }); + insertFallbackChunk(db, { id: "arbitrary", model: "/other/default.gguf", vector: [1, 0] }); + + const results = await searchVector({ + db, + vectorTable: "chunks_vec", + providerModel: "canonical-model", + providerModelAliases: ["/cache/default.gguf"], + queryVec: [1, 0], + limit: 5, + snippetMaxChars: 200, + ensureVectorReady: async () => false, + sourceFilterVec: { sql: "", params: [] }, + sourceFilterChunks: { sql: "", params: [] }, + }); + + expect(results.map((row) => row.id)).toEqual(["canonical", "alias"]); + } finally { + db.close(); + } + }); + + it("searches an empty primary model without requiring aliases", async () => { + const db = createFallbackDb(); + try { + insertFallbackChunk(db, { id: "empty-primary", model: "", vector: [1, 0] }); + insertFallbackChunk(db, { id: "other", model: "other-model", vector: [1, 0] }); + + const results = await searchVector({ + db, + vectorTable: "chunks_vec", + providerModel: "", + queryVec: [1, 0], + limit: 5, + snippetMaxChars: 200, + ensureVectorReady: async () => false, + sourceFilterVec: { sql: "", params: [] }, + sourceFilterChunks: { sql: "", params: [] }, + }); + + expect(results.map((row) => row.id)).toEqual(["empty-primary"]); + } finally { + db.close(); + } + }); + it("handles a single matching row (below the yield batch size)", async () => { const db = createFallbackDb(); try { @@ -1013,11 +1063,13 @@ describe("searchVector sqlite-vec KNN", () => { } addChunk({ id: "target-1", model: "target-model", vector: [0.5, 0.5] }); addChunk({ id: "target-2", model: "target-model", vector: [0.4, 0.6] }); + addChunk({ id: "alias-1", model: "alias-model", vector: [0.45, 0.55] }); const results = await searchVector({ db, vectorTable: "chunks_vec", providerModel: "target-model", + providerModelAliases: ["alias-model"], queryVec: [1, 0], limit: 2, snippetMaxChars: 200, @@ -1026,7 +1078,7 @@ describe("searchVector sqlite-vec KNN", () => { sourceFilterChunks: { sql: "", params: [] }, }); - expect(results.map((row) => row.id)).toEqual(["target-1", "target-2"]); + expect(results.map((row) => row.id)).toEqual(["target-1", "alias-1"]); } finally { db.close(); } diff --git a/extensions/memory-core/src/memory/manager-search.ts b/extensions/memory-core/src/memory/manager-search.ts index 57d9cd1ccbd8..62a9b3681e2b 100644 --- a/extensions/memory-core/src/memory/manager-search.ts +++ b/extensions/memory-core/src/memory/manager-search.ts @@ -94,6 +94,16 @@ function readCount(row: { count?: number | bigint } | undefined): number { return 0; } +function resolveProviderModels(primary: string, aliases: string[] | undefined): string[] { + return Array.from(new Set([primary, ...(aliases ?? []).filter(Boolean)])); +} + +function buildModelFilter(column: string, models: string[]): string { + return models.length === 1 + ? `${column} = ?` + : `${column} IN (${models.map(() => "?").join(", ")})`; +} + function planKeywordSearch(params: { query: string; ftsTokenizer?: "unicode61" | "trigram"; @@ -131,6 +141,7 @@ export async function searchVector(params: { db: DatabaseSync; vectorTable: string; providerModel: string; + providerModelAliases?: string[]; queryVec: number[]; limit: number; snippetMaxChars: number; @@ -141,6 +152,8 @@ export async function searchVector(params: { if (params.queryVec.length === 0 || params.limit <= 0) { return []; } + const providerModels = resolveProviderModels(params.providerModel, params.providerModelAliases); + const vectorModelFilter = buildModelFilter("c.model", providerModels); if (await params.ensureVectorReady(params.queryVec.length)) { // Use sqlite-vec's native KNN (MATCH ? AND k = ?) for candidate selection, // which runs in ~O(log N + k) via the vec0 index, instead of the previous @@ -158,7 +171,7 @@ export async function searchVector(params: { ` vec_distance_cosine(v.embedding, ?) AS dist\n` + ` FROM ${params.vectorTable} v\n` + ` JOIN chunks c ON c.id = v.id\n` + - ` WHERE v.embedding MATCH ? AND k = ? AND c.model = ?${params.sourceFilterVec.sql}\n` + + ` WHERE v.embedding MATCH ? AND k = ? AND ${vectorModelFilter}${params.sourceFilterVec.sql}\n` + ` ORDER BY dist ASC\n` + ` LIMIT ?`, ) @@ -166,7 +179,7 @@ export async function searchVector(params: { qBlob, qBlob, candidateLimit, - params.providerModel, + ...providerModels, ...params.sourceFilterVec.params, params.limit, ) as Array<{ @@ -185,9 +198,9 @@ export async function searchVector(params: { const matchingChunkCount = readCount( params.db .prepare( - `SELECT COUNT(*) AS count FROM chunks c WHERE c.model = ?${params.sourceFilterVec.sql}`, + `SELECT COUNT(*) AS count FROM chunks c WHERE ${vectorModelFilter}${params.sourceFilterVec.sql}`, ) - .get(params.providerModel, ...params.sourceFilterVec.params) as + .get(...providerModels, ...params.sourceFilterVec.params) as | { count?: number | bigint } | undefined, ); @@ -217,6 +230,7 @@ export async function searchVector(params: { return await searchChunksByEmbedding({ db: params.db, providerModel: params.providerModel, + providerModelAliases: params.providerModelAliases, sourceFilter: params.sourceFilterChunks, queryVec: params.queryVec, limit: params.limit, @@ -227,6 +241,7 @@ export async function searchVector(params: { async function searchChunksByEmbedding(params: { db: DatabaseSync; providerModel: string; + providerModelAliases?: string[]; sourceFilter: { sql: string; params: SearchSource[] }; queryVec: number[]; limit: number; @@ -235,13 +250,15 @@ async function searchChunksByEmbedding(params: { if (params.limit <= 0) { return []; } + const providerModels = resolveProviderModels(params.providerModel, params.providerModelAliases); + const modelFilter = buildModelFilter("model", providerModels); // Keep batches bounded instead of calling `.all()` across the entire chunks // table, and do not hold a sqlite iterator open across the setImmediate yield // below. The rowid cursor keeps memory bounded without OFFSET rescans. const stmt = params.db.prepare( `SELECT rowid, id, path, start_line, end_line, text, embedding, source\n` + ` FROM chunks\n` + - ` WHERE model = ? AND rowid > ?${params.sourceFilter.sql}\n` + + ` WHERE ${modelFilter} AND rowid > ?${params.sourceFilter.sql}\n` + ` ORDER BY rowid ASC\n` + ` LIMIT ?`, ); @@ -260,7 +277,7 @@ async function searchChunksByEmbedding(params: { let lastRowid = 0; while (true) { const batch = stmt.all( - params.providerModel, + ...providerModels, lastRowid, ...params.sourceFilter.params, FALLBACK_VECTOR_BATCH_SIZE, @@ -382,11 +399,7 @@ export async function searchKeyword(params: { ` WHERE 1=1${fallbackLikeClause}${liveChunkClause}${params.sourceFilter.sql}\n` + ` LIMIT ?`, ) - .all( - ...fallbackLikeParams, - ...params.sourceFilter.params, - params.limit, - ) as typeof rows; + .all(...fallbackLikeParams, ...params.sourceFilter.params, params.limit) as typeof rows; } } else { rows = params.db @@ -397,11 +410,7 @@ export async function searchKeyword(params: { ` WHERE 1=1${substringClause}${liveChunkClause}${params.sourceFilter.sql}\n` + ` LIMIT ?`, ) - .all( - ...substringParams, - ...params.sourceFilter.params, - params.limit, - ) as typeof rows; + .all(...substringParams, ...params.sourceFilter.params, params.limit) as typeof rows; } return rows.map((row) => { diff --git a/extensions/memory-core/src/memory/manager-sync-ops.archive-delta-bypass.test.ts b/extensions/memory-core/src/memory/manager-sync-ops.archive-delta-bypass.test.ts index 0abb4783395a..70a1d29019cc 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.archive-delta-bypass.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.archive-delta-bypass.test.ts @@ -83,6 +83,10 @@ class SessionDeltaHarness extends MemoryManagerSyncOps { return "test"; } + protected resolveProviderIndexIdentities() { + return []; + } + protected async sync(params?: SyncParams): Promise { this.syncCalls.push(params ?? {}); } diff --git a/extensions/memory-core/src/memory/manager-sync-ops.interval.test.ts b/extensions/memory-core/src/memory/manager-sync-ops.interval.test.ts index 53b5a1aa8d38..47b20385f552 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.interval.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.interval.test.ts @@ -68,6 +68,10 @@ class IntervalSyncHarness extends MemoryManagerSyncOps { return "test"; } + protected resolveProviderIndexIdentities() { + return []; + } + protected async sync(): Promise {} protected async withTimeout(promise: Promise): Promise { diff --git a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts index 3bffdc05098f..c4efebf295d2 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts @@ -93,6 +93,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps { return "test"; } + protected resolveProviderIndexIdentities() { + return []; + } + protected async sync(params?: SyncParams): Promise { this.syncCalls.push(params ?? {}); } diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 0978c18763f1..bb5f21442e30 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -41,6 +41,7 @@ import { createEmbeddingProvider, resolveEmbeddingProviderAdapterId, resolveEmbeddingProviderFallbackModel, + resolveEmbeddingProviderIndexIdentity, type EmbeddingProvider, type EmbeddingProviderId, type EmbeddingProviderRuntime, @@ -57,15 +58,18 @@ import { applyMemoryFallbackProviderState, resolveMemoryFallbackProviderRequest, resolveFallbackCurrentProviderId, + resolveMemoryPrimaryProviderRequest, type MemoryProviderLifecycleState, } from "./manager-provider-state.js"; import { acquireMemoryReindexLock, type MemoryReindexLockHandle } from "./manager-reindex-lock.js"; import { resolveConfiguredScopeHash, resolveConfiguredSourcesForMeta, + resolveMemoryIndexProviderIdentities, resolveMemoryIndexIdentityState, - type MemoryIndexMeta, type MemoryIndexIdentityState, + type MemoryIndexMeta, + type MemoryIndexProviderIdentity, } from "./manager-reindex-state.js"; import { shouldSyncSessionsForReindex } from "./manager-session-reindex.js"; import { @@ -301,6 +305,7 @@ export abstract class MemoryManagerSyncOps { protected abstract readonly cache: { enabled: boolean; maxEntries?: number }; protected abstract db: DatabaseSync; protected abstract computeProviderKey(): string; + protected abstract resolveProviderIndexIdentities(): MemoryIndexProviderIdentity[]; protected abstract sync(params?: { reason?: string; force?: boolean; @@ -493,19 +498,27 @@ export abstract class MemoryManagerSyncOps { hasIndexedChunks?: boolean; }): MemoryIndexIdentityState { const hasProviderOverride = params && "provider" in params; + const configuredIndexIdentity = + !hasProviderOverride && !this.provider && this.settings.provider !== "none" + ? resolveEmbeddingProviderIndexIdentity({ + config: this.cfg, + agentDir: resolveAgentDir(this.cfg, this.agentId), + ...resolveMemoryPrimaryProviderRequest({ settings: this.settings }), + }) + : undefined; // Plain status can compare identity before provider init. Mirror provider // init's empty-model fallback so adapter defaults do not look mismatched. const configuredProvider = this.settings.provider === "none" ? null - : { + : (configuredIndexIdentity?.provider ?? { id: resolveEmbeddingProviderAdapterId(this.settings.provider, this.cfg) ?? this.settings.provider, model: this.settings.model.trim() || resolveEmbeddingProviderFallbackModel(this.settings.provider, "", this.cfg), - }; + }); const provider = hasProviderOverride ? params.provider! : this.provider @@ -515,11 +528,35 @@ export abstract class MemoryManagerSyncOps { params && "vectorReady" in params ? Boolean(params.vectorReady) : this.vector.available === true; + const initializedProviderIdentities = + provider && + this.provider && + provider.id === this.provider.id && + provider.model === this.provider.model + ? this.resolveProviderIndexIdentities() + : []; + const configuredProviderIdentities = configuredIndexIdentity + ? resolveMemoryIndexProviderIdentities({ + provider: configuredIndexIdentity.provider, + cacheKeyData: configuredIndexIdentity.cacheKeyData, + aliases: configuredIndexIdentity.aliases, + }) + : []; + const providerIdentities = + initializedProviderIdentities.length > 0 + ? initializedProviderIdentities + : configuredProviderIdentities; + const configuredProviderKeyKnown = configuredProviderIdentities.length > 0; return resolveMemoryIndexIdentityState({ meta: params && "meta" in params ? params.meta! : this.readMeta(), provider, - providerKey: params?.providerKeyKnown === false ? undefined : (this.providerKey ?? undefined), - providerKeyKnown: params?.providerKeyKnown, + providerKey: configuredProviderKeyKnown + ? providerIdentities[0]?.providerKey + : params?.providerKeyKnown === false + ? undefined + : (this.providerKey ?? undefined), + providerAliases: providerIdentities.slice(1), + providerKeyKnown: configuredProviderKeyKnown ? true : params?.providerKeyKnown, configuredSources: resolveConfiguredSourcesForMeta(this.sources), configuredScopeHash: resolveConfiguredScopeHash({ workspaceDir: this.workspaceDir, @@ -2143,6 +2180,7 @@ export abstract class MemoryManagerSyncOps { // Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up. provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null, providerKey: this.providerKey ?? undefined, + providerAliases: this.resolveProviderIndexIdentities().slice(1), configuredSources: resolveConfiguredSourcesForMeta(this.sources), configuredScopeHash: resolveConfiguredScopeHash({ workspaceDir: this.workspaceDir, diff --git a/extensions/memory-core/src/memory/manager-sync-yield.test.ts b/extensions/memory-core/src/memory/manager-sync-yield.test.ts index 14ac277783b0..a36ef620362f 100644 --- a/extensions/memory-core/src/memory/manager-sync-yield.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-yield.test.ts @@ -42,6 +42,7 @@ vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, resolveEmbeddingProviderAdapterTransport: (providerId: string) => providerId === "local" ? "local" : "remote", + resolveEmbeddingProviderIndexIdentity: () => undefined, createEmbeddingProvider: vi.fn(), })); @@ -116,6 +117,10 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps { return "test"; } + protected resolveProviderIndexIdentities() { + return []; + } + protected async sync(): Promise {} protected async withTimeout( diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index add000afb38f..ed2562b53ef2 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -23,6 +23,7 @@ vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, resolveEmbeddingProviderAdapterTransport: (providerId: string) => providerId === "local" ? "local" : "remote", + resolveEmbeddingProviderIndexIdentity: () => undefined, resolveEmbeddingProviderFallbackModel: () => "fts-only", })); diff --git a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts index 61b8d70d7f33..1878b5a720fe 100644 --- a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts +++ b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts @@ -15,6 +15,7 @@ const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"; const DEFAULT_LMSTUDIO_EMBEDDING_MODEL = "text-embedding-nomic-embed-text-v1.5"; vi.mock("./embeddings.js", () => ({ + resolveEmbeddingProviderIndexIdentity: () => undefined, resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) => providerId === "ollama" ? DEFAULT_OLLAMA_EMBEDDING_MODEL diff --git a/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts b/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts index 79d6673c72ec..9cc5584d0b4d 100644 --- a/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts +++ b/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts @@ -21,6 +21,7 @@ vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, resolveEmbeddingProviderAdapterTransport: (providerId: string) => providerId === "local" ? "local" : "remote", + resolveEmbeddingProviderIndexIdentity: () => undefined, resolveEmbeddingProviderFallbackModel: () => "fts-only", })); diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index e51dd310a0f0..124ce4927e85 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -883,6 +883,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem db: this.db, vectorTable: VECTOR_TABLE, providerModel: this.provider.model, + providerModelAliases: this.resolveProviderIndexIdentities() + .slice(1) + .map((identity) => identity.model), queryVec, limit, snippetMaxChars: SNIPPET_MAX_CHARS, diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 6658191892f5..5090d4f60bd1 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -142,6 +142,7 @@ vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, resolveEmbeddingProviderAdapterTransport: (providerId: string) => providerId === "local" ? "local" : "remote", + resolveEmbeddingProviderIndexIdentity: () => undefined, createEmbeddingProvider: async () => ({ requestedProvider: "openai", provider: { diff --git a/src/plugin-sdk/embedding-providers.ts b/src/plugin-sdk/embedding-providers.ts index 7c0769efc9cf..5d6e9598279a 100644 --- a/src/plugin-sdk/embedding-providers.ts +++ b/src/plugin-sdk/embedding-providers.ts @@ -13,5 +13,6 @@ export type { EmbeddingProviderCallOptions, EmbeddingProviderCreateOptions, EmbeddingProviderCreateResult, + EmbeddingProviderIndexIdentity, EmbeddingProviderRuntime, } from "../plugins/embedding-providers.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-embeddings.ts b/src/plugin-sdk/memory-core-host-engine-embeddings.ts index d8b32a27fd00..dcfdb5a9a6bc 100644 --- a/src/plugin-sdk/memory-core-host-engine-embeddings.ts +++ b/src/plugin-sdk/memory-core-host-engine-embeddings.ts @@ -74,5 +74,6 @@ export type { MemoryEmbeddingProviderCallOptions, MemoryEmbeddingProviderCreateOptions, MemoryEmbeddingProviderCreateResult, + MemoryEmbeddingProviderIndexIdentity, MemoryEmbeddingProviderRuntime, } from "../plugins/memory-embedding-providers.js"; diff --git a/src/plugins/embedding-provider-types.ts b/src/plugins/embedding-provider-types.ts index 2fb48e9b7387..4502fd786b55 100644 --- a/src/plugins/embedding-provider-types.ts +++ b/src/plugins/embedding-provider-types.ts @@ -22,10 +22,25 @@ export type EmbeddingProviderCallOptions = { export type EmbeddingProviderRuntime = { id: string; cacheKeyData?: Record; + /** Prior persisted model/cache identities that are equivalent to the current identity. */ + indexIdentityAliases?: Array<{ + model: string; + cacheKeyData: Record; + }>; inlineQueryTimeoutMs?: number; inlineBatchTimeoutMs?: number; }; +/** Provider-owned canonical identity and exact aliases for persisted indexes. */ +export type EmbeddingProviderIndexIdentity = { + model: string; + cacheKeyData: Record; + aliases?: Array<{ + model: string; + cacheKeyData: Record; + }>; +}; + /** Created embedding provider instance used by memory/search callers. */ export type EmbeddingProvider = { id: string; @@ -74,6 +89,9 @@ export type EmbeddingProviderAdapter = { defaultModel?: string; transport?: "local" | "remote"; authProviderId?: string; + resolveIndexIdentity?: ( + options: EmbeddingProviderCreateOptions, + ) => EmbeddingProviderIndexIdentity; create: (options: EmbeddingProviderCreateOptions) => Promise; formatSetupError?: (err: unknown) => string; }; diff --git a/src/plugins/embedding-providers.ts b/src/plugins/embedding-providers.ts index ca9b90c59bc4..707d5f964192 100644 --- a/src/plugins/embedding-providers.ts +++ b/src/plugins/embedding-providers.ts @@ -12,6 +12,7 @@ export type { EmbeddingProviderCallOptions, EmbeddingProviderCreateOptions, EmbeddingProviderCreateResult, + EmbeddingProviderIndexIdentity, EmbeddingProviderRuntime, RegisteredEmbeddingProvider, } from "./embedding-provider-types.js"; diff --git a/src/plugins/memory-embedding-providers.ts b/src/plugins/memory-embedding-providers.ts index 5e082a066474..4be9a046df1d 100644 --- a/src/plugins/memory-embedding-providers.ts +++ b/src/plugins/memory-embedding-providers.ts @@ -29,12 +29,27 @@ export type MemoryEmbeddingProviderCallOptions = { export type MemoryEmbeddingProviderRuntime = { id: string; cacheKeyData?: Record; + /** Prior persisted model/cache identities that are equivalent to the current identity. */ + indexIdentityAliases?: Array<{ + model: string; + cacheKeyData: Record; + }>; inlineQueryTimeoutMs?: number; inlineBatchTimeoutMs?: number; sourceWideBatchEmbed?: boolean; batchEmbed?: (options: MemoryEmbeddingBatchOptions) => Promise; }; +/** Provider-owned canonical identity and exact aliases for persisted indexes. */ +export type MemoryEmbeddingProviderIndexIdentity = { + model: string; + cacheKeyData: Record; + aliases?: Array<{ + model: string; + cacheKeyData: Record; + }>; +}; + /** Created memory embedding provider instance. */ export type MemoryEmbeddingProvider = { id: string; @@ -98,6 +113,9 @@ export type MemoryEmbeddingProviderAdapter = { autoSelectPriority?: number; allowExplicitWhenConfiguredAuto?: boolean; supportsMultimodalEmbeddings?: (params: { model: string }) => boolean; + resolveIndexIdentity?: ( + options: MemoryEmbeddingProviderCreateOptions, + ) => MemoryEmbeddingProviderIndexIdentity; create: ( options: MemoryEmbeddingProviderCreateOptions, ) => Promise;