mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(memory): accept local default model path migration (#92954)
* fix(memory): accept local default model path migration Treat the official local default embedding model's hf URI and downloaded GGUF path identities as equivalent so upgraded local memory indexes do not pause solely on path-format changes. * fix(memory): satisfy local identity lint Avoid filtered array tail access in the local model filename helper while preserving the same compatibility behavior. * fix(memory): preserve local embedding identity aliases --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
aliases: Array<{
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
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<string, unknown> {
|
||||
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<EmbeddingProvider> {
|
||||
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<MemoryEmbeddingProviderCreateResult> {
|
||||
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<EmbeddingProviderCreateResult> {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<void> | 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({
|
||||
|
||||
@@ -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<string, number[]>([
|
||||
["alpha", [0.1, 0.2]],
|
||||
|
||||
@@ -7,21 +7,20 @@ import {
|
||||
|
||||
type EmbeddingCacheDb = Pick<DatabaseSync, "prepare">;
|
||||
|
||||
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<string, number[]> {
|
||||
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<string, number[]>();
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
aliases?: Array<{ model: string; cacheKeyData: Record<string, unknown> }>;
|
||||
}): 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<string>();
|
||||
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>): 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<Pick<MemoryIndexProviderIdentity, "model" | "providerKey">>;
|
||||
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<Pick<MemoryIndexProviderIdentity, "model" | "providerKey">>;
|
||||
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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -83,6 +83,10 @@ class SessionDeltaHarness extends MemoryManagerSyncOps {
|
||||
return "test";
|
||||
}
|
||||
|
||||
protected resolveProviderIndexIdentities() {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected async sync(params?: SyncParams): Promise<void> {
|
||||
this.syncCalls.push(params ?? {});
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ class IntervalSyncHarness extends MemoryManagerSyncOps {
|
||||
return "test";
|
||||
}
|
||||
|
||||
protected resolveProviderIndexIdentities() {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected async sync(): Promise<void> {}
|
||||
|
||||
protected async withTimeout<T>(promise: Promise<T>): Promise<T> {
|
||||
|
||||
@@ -93,6 +93,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
|
||||
return "test";
|
||||
}
|
||||
|
||||
protected resolveProviderIndexIdentities() {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected async sync(params?: SyncParams): Promise<void> {
|
||||
this.syncCalls.push(params ?? {});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {}
|
||||
|
||||
protected async withTimeout<T>(
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("./embeddings.js", () => ({
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
|
||||
providerId === "local" ? "local" : "remote",
|
||||
resolveEmbeddingProviderIndexIdentity: () => undefined,
|
||||
resolveEmbeddingProviderFallbackModel: () => "fts-only",
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("./embeddings.js", () => ({
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
|
||||
providerId === "local" ? "local" : "remote",
|
||||
resolveEmbeddingProviderIndexIdentity: () => undefined,
|
||||
resolveEmbeddingProviderFallbackModel: () => "fts-only",
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -13,5 +13,6 @@ export type {
|
||||
EmbeddingProviderCallOptions,
|
||||
EmbeddingProviderCreateOptions,
|
||||
EmbeddingProviderCreateResult,
|
||||
EmbeddingProviderIndexIdentity,
|
||||
EmbeddingProviderRuntime,
|
||||
} from "../plugins/embedding-providers.js";
|
||||
|
||||
@@ -74,5 +74,6 @@ export type {
|
||||
MemoryEmbeddingProviderCallOptions,
|
||||
MemoryEmbeddingProviderCreateOptions,
|
||||
MemoryEmbeddingProviderCreateResult,
|
||||
MemoryEmbeddingProviderIndexIdentity,
|
||||
MemoryEmbeddingProviderRuntime,
|
||||
} from "../plugins/memory-embedding-providers.js";
|
||||
|
||||
@@ -22,10 +22,25 @@ export type EmbeddingProviderCallOptions = {
|
||||
export type EmbeddingProviderRuntime = {
|
||||
id: string;
|
||||
cacheKeyData?: Record<string, unknown>;
|
||||
/** Prior persisted model/cache identities that are equivalent to the current identity. */
|
||||
indexIdentityAliases?: Array<{
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
}>;
|
||||
inlineQueryTimeoutMs?: number;
|
||||
inlineBatchTimeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Provider-owned canonical identity and exact aliases for persisted indexes. */
|
||||
export type EmbeddingProviderIndexIdentity = {
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
aliases?: Array<{
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 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<EmbeddingProviderCreateResult>;
|
||||
formatSetupError?: (err: unknown) => string;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
EmbeddingProviderCallOptions,
|
||||
EmbeddingProviderCreateOptions,
|
||||
EmbeddingProviderCreateResult,
|
||||
EmbeddingProviderIndexIdentity,
|
||||
EmbeddingProviderRuntime,
|
||||
RegisteredEmbeddingProvider,
|
||||
} from "./embedding-provider-types.js";
|
||||
|
||||
@@ -29,12 +29,27 @@ export type MemoryEmbeddingProviderCallOptions = {
|
||||
export type MemoryEmbeddingProviderRuntime = {
|
||||
id: string;
|
||||
cacheKeyData?: Record<string, unknown>;
|
||||
/** Prior persisted model/cache identities that are equivalent to the current identity. */
|
||||
indexIdentityAliases?: Array<{
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
}>;
|
||||
inlineQueryTimeoutMs?: number;
|
||||
inlineBatchTimeoutMs?: number;
|
||||
sourceWideBatchEmbed?: boolean;
|
||||
batchEmbed?: (options: MemoryEmbeddingBatchOptions) => Promise<number[][] | null>;
|
||||
};
|
||||
|
||||
/** Provider-owned canonical identity and exact aliases for persisted indexes. */
|
||||
export type MemoryEmbeddingProviderIndexIdentity = {
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
aliases?: Array<{
|
||||
model: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 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<MemoryEmbeddingProviderCreateResult>;
|
||||
|
||||
Reference in New Issue
Block a user