mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(memory-core): split index manager along the inheritance chain (#122540)
* refactor(memory-core): split index manager along the inheritance chain * fix(memory): restore the manager lazy boundary * refactor(memory-core): delete dead manager plumbing * chore(lint): ratchet max-lines baseline after memory manager split * fix(memory): keep the manager out of the runtime facade * test(memory): cover transitive manager facade imports
This commit is contained in:
committed by
GitHub
parent
e5dd254561
commit
7e42dae611
@@ -150,7 +150,6 @@ extensions/memory-core/src/memory/index.test.ts
|
||||
extensions/memory-core/src/memory/manager-embedding-ops.ts
|
||||
extensions/memory-core/src/memory/manager-search.test.ts
|
||||
extensions/memory-core/src/memory/manager-search.ts
|
||||
extensions/memory-core/src/memory/manager.ts
|
||||
extensions/memory-core/src/rem-evidence.ts
|
||||
extensions/memory-core/src/short-term-promotion.test.ts
|
||||
extensions/memory-core/src/tools.test.ts
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Memory Core API module exposes the plugin public contract.
|
||||
export { getMemorySearchManager, MemoryIndexManager } from "./src/memory/index.js";
|
||||
export { getMemorySearchManager } from "./src/memory/index.js";
|
||||
export { memoryRuntime } from "./src/runtime-provider.js";
|
||||
export {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
// Memory Core plugin entrypoint registers its OpenClaw integration.
|
||||
export { MemoryIndexManager } from "./manager.js";
|
||||
export {
|
||||
closeAllMemorySearchManagers,
|
||||
closeMemorySearchManager,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Memory Core tests cover asynchronous manager state helpers.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { awaitPendingManagerWork, startAsyncSearchSync } from "./manager-async-state.js";
|
||||
|
||||
describe("memory manager async state", () => {
|
||||
it("waits for in-flight search sync during close", async () => {
|
||||
let releaseSync = () => {};
|
||||
const pendingSync = new Promise<void>((resolve) => {
|
||||
releaseSync = () => resolve();
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
const closePromise = awaitPendingManagerWork({ pendingSync }).then(() => {
|
||||
closed = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(closed).toBe(false);
|
||||
|
||||
releaseSync();
|
||||
await closePromise;
|
||||
});
|
||||
|
||||
it("reports pending sync failures during close", async () => {
|
||||
const onError = vi.fn();
|
||||
const syncError = new Error("sync failed");
|
||||
|
||||
await awaitPendingManagerWork({
|
||||
pendingSync: Promise.reject(syncError),
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(syncError);
|
||||
});
|
||||
|
||||
it("reports pending provider initialization failures during close", async () => {
|
||||
const onError = vi.fn();
|
||||
const providerError = new Error("provider init failed");
|
||||
|
||||
await awaitPendingManagerWork({
|
||||
pendingProviderInit: Promise.reject(providerError),
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(providerError);
|
||||
});
|
||||
|
||||
it("does not report errors for completed pending close work", async () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
await awaitPendingManagerWork({
|
||||
pendingSync: Promise.resolve(),
|
||||
pendingProviderInit: Promise.resolve(),
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips background search sync when search-triggered sync is disabled", async () => {
|
||||
const syncMock = vi.fn(async () => {});
|
||||
await startAsyncSearchSync({
|
||||
enabled: false,
|
||||
dirty: true,
|
||||
sessionsDirty: false,
|
||||
sync: syncMock,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
expect(syncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,972 @@
|
||||
// Memory Core tests cover manager keyword retrieval behavior.
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
vi.setConfig({ testTimeout: 240_000 });
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetConfig();
|
||||
});
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embeddedBatchTexts: string[] = [];
|
||||
let embedBatchInputCalls = 0;
|
||||
let providerRuntimeBatchCalls: string[][] = [];
|
||||
let providerRuntimeBatchGate: Promise<void> | null = null;
|
||||
let providerRuntimeBatchErrors: unknown[] = [];
|
||||
let providerRuntimeBatchFailuresRemaining = 0;
|
||||
let providerRuntimeActiveBatchCalls = 0;
|
||||
let providerRuntimeMaxActiveBatchCalls = 0;
|
||||
let providerCloseCalls = 0;
|
||||
let providerCloseFailuresRemaining = 0;
|
||||
let providerCloseFailure: unknown = new Error("provider close failed");
|
||||
let providerCreationFailure: string | null = null;
|
||||
let providerNullResult: string | null = null;
|
||||
let providerCloseGate: Promise<void> | null = null;
|
||||
let providerInitGate: Promise<void> | null = null;
|
||||
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
|
||||
let forceNoProvider = false;
|
||||
|
||||
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
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_EXITED",
|
||||
reason: "exit",
|
||||
exitCode: 134,
|
||||
});
|
||||
}
|
||||
|
||||
function setMemoryIndexStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
|
||||
function restoreMemoryIndexStateDir(): void {
|
||||
if (originalMemoryIndexStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embeddings.js")>();
|
||||
const embedText = (text: string) => {
|
||||
const lower = text.toLowerCase();
|
||||
const alpha = lower.split("alpha").length - 1;
|
||||
const beta = lower.split("beta").length - 1;
|
||||
const image = lower.split("image").length - 1;
|
||||
const audio = lower.split("audio").length - 1;
|
||||
return [alpha, beta, image, audio];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => 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;
|
||||
outputDimensionality?: number;
|
||||
}) => {
|
||||
providerCalls.push({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
});
|
||||
await providerInitGate;
|
||||
if (options.provider === providerCreationFailure) {
|
||||
throw new Error(`provider creation failed: ${options.provider}`);
|
||||
}
|
||||
if (options.provider === providerNullResult) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider,
|
||||
providerUnavailableReason: `provider unavailable: ${options.provider}`,
|
||||
};
|
||||
}
|
||||
if (forceNoProvider) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider ?? "auto",
|
||||
providerUnavailableReason: "No API key found for provider",
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "batch-test" ||
|
||||
options.provider === "batch-wide-test" ||
|
||||
options.provider === identityAliasFixture.provider ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
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: {
|
||||
id: providerId,
|
||||
model,
|
||||
close: async () => {
|
||||
providerCloseCalls += 1;
|
||||
await providerCloseGate;
|
||||
if (providerCloseFailuresRemaining > 0) {
|
||||
providerCloseFailuresRemaining -= 1;
|
||||
throw providerCloseFailure;
|
||||
}
|
||||
},
|
||||
embedQuery: async (text: string) => embedText(text),
|
||||
embedBatch: async (texts: string[]) => {
|
||||
embedBatchCalls += 1;
|
||||
embeddedBatchTexts.push(...texts);
|
||||
return texts.map(embedText);
|
||||
},
|
||||
...(providerId === "gemini" || providerId === "fallback-provider"
|
||||
? {
|
||||
embedBatchInputs: async (
|
||||
inputs: Array<{
|
||||
text: string;
|
||||
parts?: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "inline-data"; mimeType: string; data: string }
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
embedBatchInputCalls += 1;
|
||||
return inputs.map((input) => {
|
||||
const inlineData = input.parts?.find((part) => part.type === "inline-data");
|
||||
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
|
||||
throw new Error("payload too large");
|
||||
}
|
||||
const mimeType =
|
||||
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return [0, 0, 1, 0];
|
||||
}
|
||||
if (mimeType?.startsWith("audio/")) {
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
return embedText(input.text);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(providerId === identityAliasFixture.provider
|
||||
? {
|
||||
runtime: {
|
||||
id: providerId,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.canonicalModel,
|
||||
},
|
||||
indexIdentityAliases: [
|
||||
{
|
||||
model: identityAliasFixture.cacheModel,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.cacheModel,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: providerId === "batch-test" || providerId === "batch-wide-test"
|
||||
? {
|
||||
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 (providerRuntimeBatchErrors.length > 0) {
|
||||
throw providerRuntimeBatchErrors.shift();
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
const managersForCleanup = new Set<MemoryIndexManager>();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
embedBatchCalls = 0;
|
||||
embeddedBatchTexts = [];
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
providerRuntimeBatchGate = null;
|
||||
providerRuntimeBatchErrors = [];
|
||||
providerRuntimeBatchFailuresRemaining = 0;
|
||||
providerRuntimeActiveBatchCalls = 0;
|
||||
providerRuntimeMaxActiveBatchCalls = 0;
|
||||
providerCloseCalls = 0;
|
||||
providerCloseFailuresRemaining = 0;
|
||||
providerCloseFailure = new Error("provider close failed");
|
||||
providerCreationFailure = null;
|
||||
providerNullResult = null;
|
||||
providerCloseGate = null;
|
||||
providerInitGate = null;
|
||||
providerCalls = [];
|
||||
forceNoProvider = false;
|
||||
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
});
|
||||
|
||||
function resetManagerForTest(manager: MemoryIndexManager) {
|
||||
// These tests reuse managers for performance. Clear the index + embedding
|
||||
// cache to keep each test fully isolated.
|
||||
const db = (
|
||||
manager as unknown as {
|
||||
db: {
|
||||
exec: (sql: string) => void;
|
||||
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
|
||||
};
|
||||
}
|
||||
).db;
|
||||
for (const table of [
|
||||
"memory_index_sources",
|
||||
"memory_index_chunks",
|
||||
"memory_embedding_cache",
|
||||
"memory_index_chunks_fts",
|
||||
"memory_index_chunks_vec",
|
||||
]) {
|
||||
const existingTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table);
|
||||
if (existingTable?.name === table) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
}
|
||||
(manager as unknown as { dirty: boolean }).dirty = true;
|
||||
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
|
||||
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
|
||||
}
|
||||
|
||||
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
|
||||
|
||||
function createCfg(params: {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
rememberAcrossConversations?: boolean;
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
batchEnabled?: boolean;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
enabled?: boolean;
|
||||
modalities?: Array<"image" | "audio" | "all">;
|
||||
maxFileBytes?: number;
|
||||
};
|
||||
vectorEnabled?: boolean;
|
||||
cacheEnabled?: boolean;
|
||||
minScore?: number;
|
||||
onSearch?: boolean;
|
||||
hybrid?: {
|
||||
enabled: boolean;
|
||||
vectorWeight?: number;
|
||||
textWeight?: number;
|
||||
temporalDecay?: { enabled: boolean };
|
||||
};
|
||||
}): TestCfg {
|
||||
return isolateMemoryManagerTestConfig({
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: {
|
||||
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
|
||||
},
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
batch: { enabled: true },
|
||||
}
|
||||
: undefined,
|
||||
query: { minScore: params.minScore ?? 0 },
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
rememberAcrossConversations:
|
||||
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedMemoryIndexSessionTranscript(params: {
|
||||
messages: Array<{
|
||||
content: string;
|
||||
role: "assistant" | "user";
|
||||
senderIsOwner?: boolean;
|
||||
timestamp: number | string;
|
||||
}>;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<void> {
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
const sessionKey = params.sessionKey ?? `agent:main:memory:${params.sessionId}`;
|
||||
// Message timestamps are behavioral inputs; entry freshness only keeps the
|
||||
// fixture out of real session-retention maintenance as wall time advances.
|
||||
const updatedAt = Date.now();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await upsertSessionEntry({
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: params.sessionId,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
for (const message of params.messages) {
|
||||
await appendSessionTranscriptMessageByIdentity({
|
||||
agentId: "main",
|
||||
sessionId: params.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
message: {
|
||||
role: message.role,
|
||||
timestamp: message.timestamp,
|
||||
content: [{ type: "text", text: message.content }],
|
||||
...(message.senderIsOwner ? { __openclaw: { senderIsOwner: true } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function requireManager(
|
||||
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||
missingMessage = "manager missing",
|
||||
): MemoryIndexManager {
|
||||
if (!result.manager) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return result.manager as unknown as MemoryIndexManager;
|
||||
}
|
||||
|
||||
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function getFtsSessionManager(params: {
|
||||
stateDirName: string;
|
||||
}): Promise<MemoryIndexManager | null> {
|
||||
forceNoProvider = true;
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
sources: ["memory", "sessions"],
|
||||
sessionMemory: true,
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager.status().fts?.available ? manager : null;
|
||||
}
|
||||
|
||||
it("builds FTS index and returns search results when no embedding provider is available", async () => {
|
||||
forceNoProvider = true;
|
||||
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0.35,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const status = manager.status();
|
||||
expect(status.chunks).toBeGreaterThan(0);
|
||||
expect(embedBatchCalls).toBe(0);
|
||||
|
||||
const results = await manager.search("Alpha");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.snippet).toMatch(/Alpha/i);
|
||||
|
||||
const noResults = await manager.search("nonexistent_xyz_keyword");
|
||||
expect(noResults.length).toBe(0);
|
||||
});
|
||||
|
||||
it("ranks an exact path stem ahead of a body match before applying the result limit", async () => {
|
||||
forceNoProvider = true;
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0.35,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(memoryDir, "project-lantern.md"), "Unrelated exact-path body.");
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "body-match.md"),
|
||||
"Project lantern project lantern project lantern.",
|
||||
);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("project-lantern", { maxResults: 1 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.path).toContain("memory/project-lantern.md");
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("does not let fallback-term filenames consume the candidate cap", async () => {
|
||||
forceNoProvider = true;
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const duplicateDir = path.join(memoryDir, `alpha-${index}`);
|
||||
await fs.mkdir(duplicateDir, { recursive: true });
|
||||
await fs.writeFile(path.join(duplicateDir, "alpha.md"), "Unrelated path-only candidate.");
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "body-match.md"),
|
||||
"Alpha alpha alpha alpha alpha strongest fallback body match.",
|
||||
);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("alpha gamma", { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.path).toContain("memory/body-match.md");
|
||||
});
|
||||
|
||||
it("bounds the merged six-term fallback candidate set", async () => {
|
||||
forceNoProvider = true;
|
||||
const manager = await getPersistentManager(
|
||||
createCfg({ provider: "none", minScore: 0, hybrid: { enabled: true } }),
|
||||
);
|
||||
const terms = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"];
|
||||
for (const term of terms) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await fs.writeFile(path.join(memoryDir, `${term}-${index}.md`), `${term} body ${index}`);
|
||||
}
|
||||
}
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search(terms.join(" "), { maxResults: 4, minScore: 0 });
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
expect(new Set(results.map((entry) => entry.path)).size).toBe(4);
|
||||
});
|
||||
|
||||
it("counts exact candidate headroom by distinct path instead of chunk", async () => {
|
||||
forceNoProvider = true;
|
||||
const manager = await getPersistentManager(
|
||||
createCfg({ provider: "none", minScore: 0, hybrid: { enabled: true } }),
|
||||
);
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const dir = path.join(memoryDir, index.toString().padStart(3, "0"));
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "foo.md"), `foo body ${index}`);
|
||||
}
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("foo.md", { maxResults: 204, minScore: 0 });
|
||||
|
||||
expect(results).toHaveLength(200);
|
||||
expect(new Set(results.map((entry) => entry.path)).size).toBe(200);
|
||||
expect(results.some((entry) => entry.path === "memory/199/foo.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses body relevance within the same exact basename tier in FTS-only mode", async () => {
|
||||
forceNoProvider = true;
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weakDir = path.join(memoryDir, "a");
|
||||
const strongDir = path.join(memoryDir, "z");
|
||||
await fs.mkdir(weakDir, { recursive: true });
|
||||
await fs.mkdir(strongDir, { recursive: true });
|
||||
await fs.writeFile(path.join(weakDir, "foo.md"), "Unrelated weak body.");
|
||||
await fs.writeFile(path.join(strongDir, "foo.md"), "foo md foo md foo md strong body");
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("foo.md", { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.path).toContain("memory/z/foo.md");
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("returns exact basename candidates with fixed FTS ranking", async () => {
|
||||
forceNoProvider = true;
|
||||
const staleDir = path.join(fixtureRoot, "decay-a-stale");
|
||||
const freshDir = path.join(fixtureRoot, "decay-z-fresh");
|
||||
await fs.mkdir(staleDir, { recursive: true });
|
||||
await fs.mkdir(freshDir, { recursive: true });
|
||||
const staleFooPath = path.join(staleDir, "foo.md");
|
||||
const freshFooPath = path.join(freshDir, "foo.md");
|
||||
const staleBarPath = path.join(staleDir, "bar.md");
|
||||
await fs.writeFile(staleFooPath, "Unrelated stale candidate.");
|
||||
await fs.writeFile(freshFooPath, "Unrelated fresh candidate.");
|
||||
await fs.writeFile(staleBarPath, "bar md bar md bar md strongest stale body");
|
||||
await fs.writeFile(path.join(freshDir, "bar.md"), "bar md fresh body");
|
||||
const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000);
|
||||
await Promise.all([
|
||||
fs.utimes(staleFooPath, staleMtime, staleMtime),
|
||||
fs.utimes(staleBarPath, staleMtime, staleMtime),
|
||||
]);
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
extraPaths: [staleDir, freshDir],
|
||||
minScore: 0,
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
for (const basename of ["foo.md", "bar.md"]) {
|
||||
const results = await manager.search(basename, { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.score).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the fixed FTS candidate cap to exact paths", async () => {
|
||||
forceNoProvider = true;
|
||||
const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000);
|
||||
const extraPaths: string[] = [];
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const suffix = index === 4 ? "z-fresh" : `a-stale-${index}`;
|
||||
const extraDir = path.join(fixtureRoot, `decay-cap-${suffix}`);
|
||||
const filePath = path.join(extraDir, "foo.md");
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
const body = index < 4 ? "foo md stale content candidate." : "Unrelated fresh candidate.";
|
||||
await fs.writeFile(filePath, body);
|
||||
if (index < 4) {
|
||||
await fs.utimes(filePath, staleMtime, staleMtime);
|
||||
}
|
||||
extraPaths.push(extraDir);
|
||||
}
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
extraPaths,
|
||||
minScore: 0,
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("foo.md", { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("applies the fixed hybrid candidate cap", async () => {
|
||||
const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000);
|
||||
const extraPaths: string[] = [];
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const suffix = index === 4 ? "z-fresh" : `a-stale-${index}`;
|
||||
const extraDir = path.join(fixtureRoot, `hybrid-decay-cap-${suffix}`);
|
||||
const filePath = path.join(extraDir, "alpha.md");
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
const body = index === 4 ? "Alpha beta lower-similarity candidate." : "Alpha candidate.";
|
||||
await fs.writeFile(filePath, body);
|
||||
if (index < 4) {
|
||||
await fs.utimes(filePath, staleMtime, staleMtime);
|
||||
}
|
||||
extraPaths.push(extraDir);
|
||||
}
|
||||
const cfg = createCfg({
|
||||
extraPaths,
|
||||
minScore: 0,
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("alpha.md", { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps fixed hybrid ranking when search degrades to keyword-only", async () => {
|
||||
const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000);
|
||||
const extraPaths: string[] = [];
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const suffix = index === 4 ? "z-fresh" : `a-stale-${index}`;
|
||||
const extraDir = path.join(fixtureRoot, `degraded-decay-cap-${suffix}`);
|
||||
const filePath = path.join(extraDir, "beta.md");
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
await fs.writeFile(filePath, "Beta equal content candidate.");
|
||||
if (index < 4) {
|
||||
await fs.utimes(filePath, staleMtime, staleMtime);
|
||||
}
|
||||
extraPaths.push(extraDir);
|
||||
}
|
||||
const cfg = createCfg({
|
||||
extraPaths,
|
||||
fallback: "none",
|
||||
minScore: 0,
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const degraded = manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: () => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
} | null;
|
||||
markLocalEmbeddingProviderDegraded: (err: unknown) => void;
|
||||
};
|
||||
const provider = degraded.provider;
|
||||
if (!provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
provider.embedQuery = async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
};
|
||||
degraded.markLocalEmbeddingProviderDegraded = () => {
|
||||
degraded.provider = null;
|
||||
};
|
||||
|
||||
const results = await manager.search("beta.md", { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps body relevance for an exact basename beyond the exact candidate cap", async () => {
|
||||
forceNoProvider = true;
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicatesDir = path.join(memoryDir, "readme-dupes");
|
||||
for (let index = 0; index < 205; index += 1) {
|
||||
const duplicateDir = path.join(duplicatesDir, `a-${index.toString().padStart(3, "0")}`);
|
||||
await fs.mkdir(duplicateDir, { recursive: true });
|
||||
await fs.writeFile(path.join(duplicateDir, "README.md"), "Unrelated weak body.");
|
||||
}
|
||||
const strongDir = path.join(duplicatesDir, "z-strong");
|
||||
await fs.mkdir(strongDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(strongDir, "README.md"),
|
||||
"README md README md README md strongest body match.",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "readme-body-only.md"),
|
||||
"README md body-only candidate.",
|
||||
);
|
||||
await fs.writeFile(path.join(memoryDir, "README.md.notes"), "Unrelated partial path.");
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("README.md", { maxResults: 1, minScore: 0 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.path).toContain("memory/readme-dupes/z-strong/README.md");
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps boosted score ordering for non-exact FTS-only body matches", async () => {
|
||||
forceNoProvider = true;
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "project-memory-notes.md"),
|
||||
"Project memory notes covering workspace context and retrieval behavior.",
|
||||
);
|
||||
await fs.writeFile(path.join(memoryDir, "notes.md"), "Project memory context.");
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("project memory context", {
|
||||
maxResults: 1,
|
||||
minScore: 0,
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.path).toContain("memory/project-memory-notes.md");
|
||||
expect(results[0]?.score).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("keeps an exact dated path ahead in FTS-only mode", async () => {
|
||||
forceNoProvider = true;
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
minScore: 0.35,
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(memoryDir, "2020-01-01.md"), "Unrelated exact-path body.");
|
||||
await fs.writeFile(path.join(memoryDir, "body-match.md"), "2020 01 01 2020 01 01 2020 01 01");
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("2020-01-01", { maxResults: 1 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.path).toContain("memory/2020-01-01.md");
|
||||
expect(results[0]?.score).toBe(1);
|
||||
});
|
||||
|
||||
it("prefers exact session transcript hits in FTS-only mode", async () => {
|
||||
try {
|
||||
const manager = await getFtsSessionManager({
|
||||
stateDirName: ".state-session-ranking",
|
||||
});
|
||||
if (!manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
await fs.writeFile(memoryPath, "Project Nebula stale codename: ORBIT-9.\n", "utf8");
|
||||
const staleAt = new Date("2020-01-01T00:00:00.000Z");
|
||||
await fs.utimes(memoryPath, staleAt, staleAt);
|
||||
|
||||
const now = Date.parse("2026-04-07T15:25:04.113Z");
|
||||
await seedMemoryIndexSessionTranscript({
|
||||
sessionId: "session-ranking",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
timestamp: new Date(now - 30_000).toISOString(),
|
||||
content: "What is the current Project Nebula codename?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: new Date(now).toISOString(),
|
||||
content: "The current Project Nebula codename is ORBIT-10.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await manager.sync({ reason: "test", force: true });
|
||||
const results = await manager.search("current Project Nebula codename ORBIT-10", {
|
||||
minScore: 0,
|
||||
maxResults: 3,
|
||||
});
|
||||
|
||||
expect(results[0]?.source).toBe("sessions");
|
||||
expect(results[0]?.snippet).toContain("ORBIT-10");
|
||||
expect(results[0]?.provenance).toMatchObject({
|
||||
originClass: "untrusted",
|
||||
sessionKind: "interactive",
|
||||
});
|
||||
} finally {
|
||||
restoreMemoryIndexStateDir();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
// Memory Core plugin module owns keyword retrieval and ranking.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import { extractKeywords } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
|
||||
import {
|
||||
readCuratedProjectMemoryCandidates,
|
||||
readCuratedMemoryTriggerCandidates,
|
||||
readMemoryRecallMetadata,
|
||||
MEMORY_INDEX_FTS_TABLE,
|
||||
MEMORY_INDEX_PATHS_FTS_TABLE,
|
||||
type MemorySearchResult,
|
||||
type MemorySource,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { bm25RankToScore, buildFtsQuery, scoreExactPathTieForTemporalDecay } from "./hybrid.js";
|
||||
import { applyImportanceMultiplier } from "./importance.js";
|
||||
import { MemoryProviderLifecycle } from "./manager-provider-lifecycle.js";
|
||||
import {
|
||||
resolveExactPathSpecificity,
|
||||
searchKeyword,
|
||||
searchPathKeyword,
|
||||
type ExactPathSpecificity,
|
||||
} from "./manager-search.js";
|
||||
import { applyProjectRanking } from "./project-ranking.js";
|
||||
import { applyTemporalDecayToHybridResults } from "./temporal-decay.js";
|
||||
|
||||
const SNIPPET_MAX_CHARS = 700;
|
||||
const FTS_TABLE = MEMORY_INDEX_FTS_TABLE;
|
||||
const PATH_FTS_TABLE = MEMORY_INDEX_PATHS_FTS_TABLE;
|
||||
const KEYWORD_FALLBACK_SEARCH_TERM_LIMIT = 6;
|
||||
const EXACT_PATH_CANDIDATE_LIMIT = 200;
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
export type KeywordSearchHit = MemorySearchResult & {
|
||||
id: string;
|
||||
textScore: number;
|
||||
pathScore: number;
|
||||
exactPathSpecificity: ExactPathSpecificity;
|
||||
};
|
||||
|
||||
function compareKeywordSearchHits(
|
||||
a: KeywordSearchHit,
|
||||
b: KeywordSearchHit,
|
||||
preferExactBody = true,
|
||||
): number {
|
||||
const specificityDelta = b.exactPathSpecificity - a.exactPathSpecificity;
|
||||
if (specificityDelta !== 0) {
|
||||
return specificityDelta;
|
||||
}
|
||||
if (preferExactBody && a.exactPathSpecificity > 0) {
|
||||
const bodyPresenceDelta = Number(b.textScore > 0) - Number(a.textScore > 0);
|
||||
if (bodyPresenceDelta !== 0) {
|
||||
return bodyPresenceDelta;
|
||||
}
|
||||
}
|
||||
// Score carries body relevance plus any configured decay. Exact tiers ignore
|
||||
// path BM25 because specificity already owns path precedence.
|
||||
const relevanceDelta = b.score - a.score;
|
||||
if (relevanceDelta !== 0) {
|
||||
return relevanceDelta;
|
||||
}
|
||||
const textDelta = b.textScore - a.textScore;
|
||||
if (textDelta !== 0) {
|
||||
return textDelta;
|
||||
}
|
||||
if (a.exactPathSpecificity === 0) {
|
||||
const pathDelta = b.pathScore - a.pathScore;
|
||||
if (pathDelta !== 0) {
|
||||
return pathDelta;
|
||||
}
|
||||
}
|
||||
return a.path.localeCompare(b.path) || a.startLine - b.startLine || a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
export abstract class MemoryKeywordRetrieval extends MemoryProviderLifecycle {
|
||||
private selectScoredResults<T extends MemorySearchResult & { score: number }>(
|
||||
results: T[],
|
||||
maxResults: number,
|
||||
minScore: number,
|
||||
relaxedMinScore = minScore,
|
||||
): T[] {
|
||||
const strict = results.filter((entry) => entry.score >= minScore);
|
||||
if (strict.length > 0) {
|
||||
return strict.slice(0, maxResults);
|
||||
}
|
||||
return results.filter((entry) => entry.score >= relaxedMinScore).slice(0, maxResults);
|
||||
}
|
||||
|
||||
async listTriggerCandidates(opts?: {
|
||||
limit?: number;
|
||||
activeProjectKeys?: string[];
|
||||
}): Promise<MemorySearchResult[]> {
|
||||
const limit = Math.max(1, Math.min(512, Math.floor(opts?.limit ?? 512)));
|
||||
return this.toCuratedMemorySearchResults(
|
||||
readCuratedMemoryTriggerCandidates(this.db, limit, opts?.activeProjectKeys),
|
||||
);
|
||||
}
|
||||
|
||||
async listCuratedProjectCandidates(opts: {
|
||||
activeProjectKeys: string[];
|
||||
limit?: number;
|
||||
}): Promise<MemorySearchResult[]> {
|
||||
const limit = Math.max(1, Math.min(512, Math.floor(opts.limit ?? 48)));
|
||||
return this.toCuratedMemorySearchResults(
|
||||
readCuratedProjectMemoryCandidates(this.db, limit, opts.activeProjectKeys),
|
||||
);
|
||||
}
|
||||
|
||||
private toCuratedMemorySearchResults(
|
||||
rows: ReturnType<typeof readCuratedMemoryTriggerCandidates>,
|
||||
): MemorySearchResult[] {
|
||||
return rows.map((row) => {
|
||||
const result: MemorySearchResult = {
|
||||
path: row.path,
|
||||
startLine: row.start_line,
|
||||
endLine: row.end_line,
|
||||
score: 0,
|
||||
snippet: row.text,
|
||||
source: "memory",
|
||||
};
|
||||
if (typeof row.importance === "number") {
|
||||
result.importance = row.importance;
|
||||
}
|
||||
if (typeof row.triggers === "string" && row.triggers.trim()) {
|
||||
result.triggers = row.triggers.trim();
|
||||
}
|
||||
if (typeof row.project_key === "string" && row.project_key.trim()) {
|
||||
result.projectKey = row.project_key.trim();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private rankKeywordOnlyResults(
|
||||
results: KeywordSearchHit[],
|
||||
preferExactBody = true,
|
||||
): KeywordSearchHit[] {
|
||||
return results
|
||||
.toSorted((left, right) => compareKeywordSearchHits(left, right, preferExactBody))
|
||||
.map((entry) =>
|
||||
entry.exactPathSpecificity > 0 ? Object.assign(entry, { score: 1 }) : entry,
|
||||
);
|
||||
}
|
||||
|
||||
protected async finalizeKeywordOnlyResults(params: {
|
||||
results: KeywordSearchHit[];
|
||||
temporalDecay?: { enabled: boolean; halfLifeDays: number };
|
||||
maxResults: number;
|
||||
minScore: number;
|
||||
activeProjectKeys?: readonly string[];
|
||||
}): Promise<MemorySearchResult[]> {
|
||||
const appliesTemporalDecay = params.temporalDecay?.enabled === true;
|
||||
const decayInputs = appliesTemporalDecay
|
||||
? params.results.map((entry) => {
|
||||
if (entry.exactPathSpecificity === 0) {
|
||||
return entry;
|
||||
}
|
||||
const contentScore = entry.textScore > 0 ? entry.score : 0;
|
||||
return { ...entry, score: scoreExactPathTieForTemporalDecay(contentScore) };
|
||||
})
|
||||
: params.results;
|
||||
const decayed = await applyTemporalDecayToHybridResults({
|
||||
results: decayInputs,
|
||||
temporalDecay: params.temporalDecay,
|
||||
workspaceDir: this.workspaceDir,
|
||||
});
|
||||
const ranked = applyProjectRanking(
|
||||
this.rankKeywordOnlyResults(applyImportanceMultiplier(decayed), !appliesTemporalDecay),
|
||||
params.activeProjectKeys,
|
||||
);
|
||||
return this.toMemorySearchResults(
|
||||
this.selectScoredResults(ranked, params.maxResults, params.minScore, 0),
|
||||
);
|
||||
}
|
||||
|
||||
protected attachRecallMetadata<T extends MemorySearchResult & { id: string }>(results: T[]): T[] {
|
||||
if (results.length === 0) {
|
||||
return results;
|
||||
}
|
||||
const metadataById = readMemoryRecallMetadata(
|
||||
this.db,
|
||||
results.map((entry) => entry.id),
|
||||
);
|
||||
return results.map((entry) => {
|
||||
const row = metadataById.get(entry.id);
|
||||
return {
|
||||
...entry,
|
||||
...(typeof row?.importance === "number" ? { importance: row.importance } : {}),
|
||||
...(typeof row?.triggers === "string" && row.triggers.trim()
|
||||
? { triggers: row.triggers.trim() }
|
||||
: {}),
|
||||
...(typeof row?.project_key === "string" && row.project_key.trim()
|
||||
? { projectKey: row.project_key.trim() }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async searchKeyword(
|
||||
query: string,
|
||||
limit: number,
|
||||
options?: {
|
||||
boostFallbackRanking?: boolean;
|
||||
exactPathQuery?: string;
|
||||
rankingQuery?: string;
|
||||
},
|
||||
sourceFilterList?: MemorySource[],
|
||||
): Promise<KeywordSearchHit[]> {
|
||||
if (!this.fts.enabled || !this.fts.available) {
|
||||
return [];
|
||||
}
|
||||
const bodySearch = searchKeyword({
|
||||
db: this.db,
|
||||
ftsTable: FTS_TABLE,
|
||||
query,
|
||||
ftsTokenizer: this.settings.store.fts.tokenizer,
|
||||
limit,
|
||||
snippetMaxChars: SNIPPET_MAX_CHARS,
|
||||
sourceFilter: this.buildSourceFilter(undefined, sourceFilterList),
|
||||
buildFtsQuery,
|
||||
bm25RankToScore,
|
||||
boostFallbackRanking: options?.boostFallbackRanking,
|
||||
rankingQuery: options?.rankingQuery,
|
||||
}).catch((err: unknown) => {
|
||||
log.warn(`memory search: body keyword query failed: ${formatErrorMessage(err)}`);
|
||||
return [];
|
||||
});
|
||||
const exactPathQuery = options?.exactPathQuery ?? query;
|
||||
const pathSearch = searchPathKeyword({
|
||||
db: this.db,
|
||||
pathFtsTable: PATH_FTS_TABLE,
|
||||
query,
|
||||
exactPathQuery,
|
||||
exactPathLimit: EXACT_PATH_CANDIDATE_LIMIT,
|
||||
ftsTokenizer: this.settings.store.fts.tokenizer,
|
||||
limit,
|
||||
snippetMaxChars: SNIPPET_MAX_CHARS,
|
||||
sourceFilter: this.buildSourceFilter(PATH_FTS_TABLE, sourceFilterList),
|
||||
buildFtsQuery,
|
||||
bm25RankToScore,
|
||||
}).catch((err: unknown) => {
|
||||
log.warn(`memory search: path keyword query failed: ${formatErrorMessage(err)}`);
|
||||
return [];
|
||||
});
|
||||
const [bodyResults, pathResults] = await Promise.all([bodySearch, pathSearch]);
|
||||
const merged = this.mergeKeywordSearchHits(
|
||||
[
|
||||
bodyResults.map((entry) =>
|
||||
Object.assign(entry, {
|
||||
exactPathSpecificity: resolveExactPathSpecificity(exactPathQuery, entry.path),
|
||||
pathScore: 0,
|
||||
}),
|
||||
),
|
||||
pathResults,
|
||||
],
|
||||
exactPathQuery,
|
||||
);
|
||||
return this.attachRecallMetadata(this.limitKeywordSearchHits(merged, limit));
|
||||
}
|
||||
|
||||
protected async searchKeywordWithFallback(
|
||||
query: string,
|
||||
limit: number,
|
||||
options: { boostFallbackRanking?: boolean } | undefined,
|
||||
sourceFilterList: MemorySource[],
|
||||
): Promise<KeywordSearchHit[]> {
|
||||
const fullQueryResults = await this.searchKeyword(
|
||||
query,
|
||||
limit,
|
||||
options,
|
||||
sourceFilterList,
|
||||
).catch(() => []);
|
||||
const nonExactResults = fullQueryResults.filter((result) => result.exactPathSpecificity === 0);
|
||||
if (nonExactResults.length >= limit) {
|
||||
return fullQueryResults;
|
||||
}
|
||||
|
||||
// Supplement thin candidate pools for conversational queries, but cap the
|
||||
// extra FTS probes so long prompts cannot fan out into unbounded sqlite work.
|
||||
const fallbackTerms = this.resolveKeywordFallbackTerms(query);
|
||||
if (fallbackTerms.length === 0) {
|
||||
return fullQueryResults;
|
||||
}
|
||||
const strictFtsQuery = buildFtsQuery(query)?.toLowerCase();
|
||||
const keywordFtsQuery = buildFtsQuery(fallbackTerms.join(" "))?.toLowerCase();
|
||||
if (fullQueryResults.length > 0 && strictFtsQuery === keywordFtsQuery) {
|
||||
// Expansion did not normalize this already-matching keyword query; OR
|
||||
// probes can only weaken its strict relevance before importance ranking.
|
||||
return fullQueryResults;
|
||||
}
|
||||
|
||||
const resultSets = await Promise.all(
|
||||
fallbackTerms.map((term) =>
|
||||
this.searchKeyword(
|
||||
term,
|
||||
limit,
|
||||
{ ...options, exactPathQuery: query, rankingQuery: query },
|
||||
sourceFilterList,
|
||||
).catch(() => []),
|
||||
),
|
||||
);
|
||||
return this.limitKeywordSearchHits(
|
||||
this.mergeKeywordSearchHits([fullQueryResults, ...resultSets], query),
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
private resolveKeywordFallbackTerms(query: string): string[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const keywords = extractKeywords(query, {
|
||||
ftsTokenizer: this.settings.store.fts.tokenizer,
|
||||
}).filter((term) => term !== normalizedQuery);
|
||||
return keywords.slice(0, KEYWORD_FALLBACK_SEARCH_TERM_LIMIT);
|
||||
}
|
||||
|
||||
private mergeKeywordSearchHits(
|
||||
resultSets: KeywordSearchHit[][],
|
||||
exactPathQuery?: string,
|
||||
): KeywordSearchHit[] {
|
||||
const seenIds = new Map<string, KeywordSearchHit>();
|
||||
for (const results of resultSets) {
|
||||
for (const result of results) {
|
||||
const existing = seenIds.get(result.id);
|
||||
if (!existing) {
|
||||
seenIds.set(result.id, result);
|
||||
continue;
|
||||
}
|
||||
const existingHasBody = existing.textScore > 0;
|
||||
const resultHasBody = result.textScore > 0;
|
||||
const existingBodyScore = existingHasBody ? existing.score : 0;
|
||||
const resultBodyScore = resultHasBody ? result.score : 0;
|
||||
existing.textScore = Math.max(existing.textScore, result.textScore);
|
||||
existing.pathScore = Math.max(existing.pathScore, result.pathScore);
|
||||
existing.exactPathSpecificity = Math.max(
|
||||
existing.exactPathSpecificity,
|
||||
result.exactPathSpecificity,
|
||||
) as ExactPathSpecificity;
|
||||
const bodyScore = Math.max(existingBodyScore, resultBodyScore);
|
||||
existing.score = bodyScore > 0 ? bodyScore : existing.pathScore;
|
||||
// Path hits project the first chunk; keep a real body-match snippet
|
||||
// authoritative when both retrieval surfaces find the same document.
|
||||
if (
|
||||
(resultHasBody && !existingHasBody) ||
|
||||
(resultHasBody === existingHasBody && result.snippet.length > existing.snippet.length)
|
||||
) {
|
||||
existing.snippet = result.snippet;
|
||||
}
|
||||
}
|
||||
}
|
||||
const merged = [...seenIds.values()];
|
||||
if (exactPathQuery !== undefined) {
|
||||
// Fallback terms broaden lexical recall, but only the original user query
|
||||
// can claim exact path, basename, or stem precedence.
|
||||
for (const result of merged) {
|
||||
result.exactPathSpecificity = resolveExactPathSpecificity(exactPathQuery, result.path);
|
||||
}
|
||||
}
|
||||
for (const result of merged) {
|
||||
if (result.textScore === 0) {
|
||||
// A uniform exact-only baseline lets temporal decay order otherwise
|
||||
// equivalent filename hits without reusing incomparable path BM25.
|
||||
result.score = result.exactPathSpecificity > 0 ? 1 : result.pathScore;
|
||||
}
|
||||
}
|
||||
return merged.toSorted(compareKeywordSearchHits);
|
||||
}
|
||||
|
||||
private limitKeywordSearchHits(
|
||||
results: KeywordSearchHit[],
|
||||
nonExactLimit: number,
|
||||
): KeywordSearchHit[] {
|
||||
const ranked = results.toSorted(compareKeywordSearchHits);
|
||||
const exactBody = ranked
|
||||
.filter((entry) => entry.exactPathSpecificity > 0 && entry.textScore > 0)
|
||||
.slice(0, nonExactLimit);
|
||||
const exactPathOnly = ranked.filter(
|
||||
(entry) => entry.exactPathSpecificity > 0 && entry.textScore === 0,
|
||||
);
|
||||
const boundedExact = exactBody.concat(exactPathOnly).toSorted(compareKeywordSearchHits);
|
||||
const selectedPathKeys = new Set<string>();
|
||||
for (const entry of boundedExact) {
|
||||
selectedPathKeys.add(`${entry.source}:${entry.path}`);
|
||||
if (selectedPathKeys.size === EXACT_PATH_CANDIDATE_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const exact = boundedExact.filter((entry) =>
|
||||
selectedPathKeys.has(`${entry.source}:${entry.path}`),
|
||||
);
|
||||
const nonExact = ranked
|
||||
.filter((entry) => entry.exactPathSpecificity === 0)
|
||||
.slice(0, nonExactLimit);
|
||||
return exact.concat(nonExact);
|
||||
}
|
||||
|
||||
protected toMemorySearchResults(results: KeywordSearchHit[]): MemorySearchResult[] {
|
||||
return results.map(
|
||||
({
|
||||
id: _id,
|
||||
pathScore: _pathScore,
|
||||
exactPathSpecificity: _exactPathSpecificity,
|
||||
...result
|
||||
}) => result,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
// Memory Core tests cover manager provider lifecycle fallback behavior.
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
vi.setConfig({ testTimeout: 240_000 });
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetConfig();
|
||||
});
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embeddedBatchTexts: string[] = [];
|
||||
let embedBatchInputCalls = 0;
|
||||
let providerRuntimeBatchCalls: string[][] = [];
|
||||
let providerRuntimeBatchGate: Promise<void> | null = null;
|
||||
let providerRuntimeBatchErrors: unknown[] = [];
|
||||
let providerRuntimeBatchFailuresRemaining = 0;
|
||||
let providerRuntimeActiveBatchCalls = 0;
|
||||
let providerRuntimeMaxActiveBatchCalls = 0;
|
||||
let providerCloseCalls = 0;
|
||||
let providerCloseFailuresRemaining = 0;
|
||||
let providerCloseFailure: unknown = new Error("provider close failed");
|
||||
let providerCreationFailure: string | null = null;
|
||||
let providerNullResult: string | null = null;
|
||||
let providerCloseGate: Promise<void> | null = null;
|
||||
let providerInitGate: Promise<void> | null = null;
|
||||
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
|
||||
let forceNoProvider = false;
|
||||
|
||||
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
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_EXITED",
|
||||
reason: "exit",
|
||||
exitCode: 134,
|
||||
});
|
||||
}
|
||||
|
||||
function setMemoryIndexStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
|
||||
function restoreMemoryIndexStateDir(): void {
|
||||
if (originalMemoryIndexStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embeddings.js")>();
|
||||
const embedText = (text: string) => {
|
||||
const lower = text.toLowerCase();
|
||||
const alpha = lower.split("alpha").length - 1;
|
||||
const beta = lower.split("beta").length - 1;
|
||||
const image = lower.split("image").length - 1;
|
||||
const audio = lower.split("audio").length - 1;
|
||||
return [alpha, beta, image, audio];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => 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;
|
||||
outputDimensionality?: number;
|
||||
}) => {
|
||||
providerCalls.push({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
});
|
||||
await providerInitGate;
|
||||
if (options.provider === providerCreationFailure) {
|
||||
throw new Error(`provider creation failed: ${options.provider}`);
|
||||
}
|
||||
if (options.provider === providerNullResult) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider,
|
||||
providerUnavailableReason: `provider unavailable: ${options.provider}`,
|
||||
};
|
||||
}
|
||||
if (forceNoProvider) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider ?? "auto",
|
||||
providerUnavailableReason: "No API key found for provider",
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "batch-test" ||
|
||||
options.provider === "batch-wide-test" ||
|
||||
options.provider === identityAliasFixture.provider ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
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: {
|
||||
id: providerId,
|
||||
model,
|
||||
close: async () => {
|
||||
providerCloseCalls += 1;
|
||||
await providerCloseGate;
|
||||
if (providerCloseFailuresRemaining > 0) {
|
||||
providerCloseFailuresRemaining -= 1;
|
||||
throw providerCloseFailure;
|
||||
}
|
||||
},
|
||||
embedQuery: async (text: string) => embedText(text),
|
||||
embedBatch: async (texts: string[]) => {
|
||||
embedBatchCalls += 1;
|
||||
embeddedBatchTexts.push(...texts);
|
||||
return texts.map(embedText);
|
||||
},
|
||||
...(providerId === "gemini" || providerId === "fallback-provider"
|
||||
? {
|
||||
embedBatchInputs: async (
|
||||
inputs: Array<{
|
||||
text: string;
|
||||
parts?: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "inline-data"; mimeType: string; data: string }
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
embedBatchInputCalls += 1;
|
||||
return inputs.map((input) => {
|
||||
const inlineData = input.parts?.find((part) => part.type === "inline-data");
|
||||
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
|
||||
throw new Error("payload too large");
|
||||
}
|
||||
const mimeType =
|
||||
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return [0, 0, 1, 0];
|
||||
}
|
||||
if (mimeType?.startsWith("audio/")) {
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
return embedText(input.text);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(providerId === identityAliasFixture.provider
|
||||
? {
|
||||
runtime: {
|
||||
id: providerId,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.canonicalModel,
|
||||
},
|
||||
indexIdentityAliases: [
|
||||
{
|
||||
model: identityAliasFixture.cacheModel,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.cacheModel,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: providerId === "batch-test" || providerId === "batch-wide-test"
|
||||
? {
|
||||
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 (providerRuntimeBatchErrors.length > 0) {
|
||||
throw providerRuntimeBatchErrors.shift();
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
const managersForCleanup = new Set<MemoryIndexManager>();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
embedBatchCalls = 0;
|
||||
embeddedBatchTexts = [];
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
providerRuntimeBatchGate = null;
|
||||
providerRuntimeBatchErrors = [];
|
||||
providerRuntimeBatchFailuresRemaining = 0;
|
||||
providerRuntimeActiveBatchCalls = 0;
|
||||
providerRuntimeMaxActiveBatchCalls = 0;
|
||||
providerCloseCalls = 0;
|
||||
providerCloseFailuresRemaining = 0;
|
||||
providerCloseFailure = new Error("provider close failed");
|
||||
providerCreationFailure = null;
|
||||
providerNullResult = null;
|
||||
providerCloseGate = null;
|
||||
providerInitGate = null;
|
||||
providerCalls = [];
|
||||
forceNoProvider = false;
|
||||
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
});
|
||||
|
||||
function resetManagerForTest(manager: MemoryIndexManager) {
|
||||
// These tests reuse managers for performance. Clear the index + embedding
|
||||
// cache to keep each test fully isolated.
|
||||
const db = (
|
||||
manager as unknown as {
|
||||
db: {
|
||||
exec: (sql: string) => void;
|
||||
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
|
||||
};
|
||||
}
|
||||
).db;
|
||||
for (const table of [
|
||||
"memory_index_sources",
|
||||
"memory_index_chunks",
|
||||
"memory_embedding_cache",
|
||||
"memory_index_chunks_fts",
|
||||
"memory_index_chunks_vec",
|
||||
]) {
|
||||
const existingTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table);
|
||||
if (existingTable?.name === table) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
}
|
||||
(manager as unknown as { dirty: boolean }).dirty = true;
|
||||
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
|
||||
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
|
||||
}
|
||||
|
||||
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
|
||||
|
||||
function createCfg(params: {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
rememberAcrossConversations?: boolean;
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
batchEnabled?: boolean;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
enabled?: boolean;
|
||||
modalities?: Array<"image" | "audio" | "all">;
|
||||
maxFileBytes?: number;
|
||||
};
|
||||
vectorEnabled?: boolean;
|
||||
cacheEnabled?: boolean;
|
||||
minScore?: number;
|
||||
onSearch?: boolean;
|
||||
hybrid?: {
|
||||
enabled: boolean;
|
||||
vectorWeight?: number;
|
||||
textWeight?: number;
|
||||
temporalDecay?: { enabled: boolean };
|
||||
};
|
||||
}): TestCfg {
|
||||
return isolateMemoryManagerTestConfig({
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: {
|
||||
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
|
||||
},
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
batch: { enabled: true },
|
||||
}
|
||||
: undefined,
|
||||
query: { minScore: params.minScore ?? 0 },
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
rememberAcrossConversations:
|
||||
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function requireManager(
|
||||
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||
missingMessage = "manager missing",
|
||||
): MemoryIndexManager {
|
||||
if (!result.manager) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return result.manager as unknown as MemoryIndexManager;
|
||||
}
|
||||
|
||||
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function getFreshManager(
|
||||
cfg: TestCfg,
|
||||
purpose?: "default" | "status" | "cli",
|
||||
): Promise<MemoryIndexManager> {
|
||||
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
|
||||
managersForCleanup.add(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
it("does not activate fallback during search when index identity is already mismatched", async () => {
|
||||
const cfg = createCfg({
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
|
||||
await manager.sync({ reason: "test" });
|
||||
const callsBeforeSearch = providerCalls.length;
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: () => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
).provider = {
|
||||
id: "local",
|
||||
model: "mock-embed",
|
||||
embedQuery: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [1, 0, 0, 0]),
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
const results = await manager.search("alpha");
|
||||
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(providerCalls.slice(callsBeforeSearch)).toStrictEqual([]);
|
||||
expect(
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: { id: string } | null;
|
||||
}
|
||||
).provider?.id,
|
||||
).toBe("local");
|
||||
});
|
||||
|
||||
it("rebuilds with fallback provider during explicit identity repair", async () => {
|
||||
const oldCfg = createCfg({
|
||||
model: "old-embed",
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const cfg = createCfg({
|
||||
model: "new-embed",
|
||||
fallback: "fallback-provider",
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
try {
|
||||
expect(manager.status().dirty).toBe(true);
|
||||
const fields = manager as unknown as {
|
||||
providerInitialized: boolean;
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
fields.providerInitialized = true;
|
||||
fields.provider = {
|
||||
id: "mock",
|
||||
model: "new-embed",
|
||||
embedQuery: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
embedBatch: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
await manager.sync({ reason: "cli" });
|
||||
|
||||
expect(manager.status().dirty).toBe(false);
|
||||
expect(manager.status().provider).toBe("fallback-provider");
|
||||
expect(manager.status().model).toBe("fallback-provider-embed");
|
||||
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
await expect(manager.search("alpha")).resolves.not.toStrictEqual([]);
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("reinitializes the configured provider after probe-time local degradation", async () => {
|
||||
const cfg = createCfg({
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
|
||||
await manager.sync({ reason: "test" });
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: () => Promise<number[]>;
|
||||
embedBatch: () => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
).provider = {
|
||||
id: "local",
|
||||
model: "mock-embed",
|
||||
embedQuery: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
embedBatch: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
const callsBeforeSearch = providerCalls.length;
|
||||
|
||||
await expect(manager.probeEmbeddingAvailability()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining("Local embedding worker exited"),
|
||||
});
|
||||
|
||||
const results = await manager.search("alpha");
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain("openai");
|
||||
expect(
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: { id: string } | null;
|
||||
}
|
||||
).provider?.id,
|
||||
).toBe("mock");
|
||||
});
|
||||
|
||||
it("clears identity dirty after status resolves the indexed fallback provider", async () => {
|
||||
const indexedCfg = createCfg({
|
||||
provider: "fallback-provider",
|
||||
model: "new-embed",
|
||||
});
|
||||
const indexedManager = await getFreshManager(indexedCfg);
|
||||
await indexedManager.sync({ reason: "test", force: true });
|
||||
await indexedManager.close?.();
|
||||
|
||||
const cfg = createCfg({
|
||||
fallback: "fallback-provider",
|
||||
model: "new-embed",
|
||||
});
|
||||
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
|
||||
const manager = await getRequiredMemoryIndexManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
purpose: "status",
|
||||
});
|
||||
try {
|
||||
expect(manager.status().dirty).toBe(true);
|
||||
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
providerInitialized: boolean;
|
||||
providerRuntime: {
|
||||
id: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
};
|
||||
providerKey: string;
|
||||
computeProviderKey: () => string;
|
||||
};
|
||||
fields.provider = {
|
||||
id: "fallback-provider",
|
||||
model: "new-embed",
|
||||
embedQuery: async () => [1, 0, 0, 0],
|
||||
embedBatch: async (texts) => texts.map(() => [1, 0, 0, 0]),
|
||||
close: async () => {},
|
||||
};
|
||||
fields.providerRuntime = {
|
||||
id: "fallback-provider",
|
||||
cacheKeyData: {
|
||||
provider: "fallback-provider",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
model: "new-embed",
|
||||
headers: [],
|
||||
},
|
||||
};
|
||||
fields.providerInitialized = true;
|
||||
fields.providerKey = fields.computeProviderKey();
|
||||
|
||||
expect(manager.status().dirty).toBe(false);
|
||||
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes already-created local runtime facts without probing embeddings", async () => {
|
||||
const cfg = createCfg({});
|
||||
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
|
||||
const manager = await getRequiredMemoryIndexManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
purpose: "status",
|
||||
});
|
||||
try {
|
||||
const getRuntimeFacts = vi.fn(() => ({
|
||||
engine: "llama.cpp" as const,
|
||||
state: "ready" as const,
|
||||
backend: "cuda" as const,
|
||||
buildType: "prebuilt" as const,
|
||||
deviceNames: ["NVIDIA Test GPU"],
|
||||
offload: {
|
||||
supported: true,
|
||||
offloadedLayers: 24,
|
||||
totalLayers: 24,
|
||||
},
|
||||
context: {
|
||||
requestedSize: 4096,
|
||||
},
|
||||
}));
|
||||
const provider = {
|
||||
id: "local",
|
||||
model: "test-model.gguf",
|
||||
embedQuery: vi.fn(async () => [1, 0, 0, 0]),
|
||||
embedBatch: vi.fn(async (texts: string[]) => texts.map(() => [1, 0, 0, 0])),
|
||||
};
|
||||
Object.defineProperty(provider, Symbol.for("openclaw.localEmbeddingRuntimeFacts"), {
|
||||
value: getRuntimeFacts,
|
||||
});
|
||||
const fields = manager as unknown as {
|
||||
provider: typeof provider | null;
|
||||
};
|
||||
fields.provider = provider;
|
||||
|
||||
expect(manager.status().custom?.llamaCppRuntime).toMatchObject({
|
||||
state: "ready",
|
||||
backend: "cuda",
|
||||
deviceNames: ["NVIDIA Test GPU"],
|
||||
offload: {
|
||||
offloadedLayers: 24,
|
||||
totalLayers: 24,
|
||||
},
|
||||
context: {
|
||||
requestedSize: 4096,
|
||||
},
|
||||
});
|
||||
expect(getRuntimeFacts).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast instead of searching FTS when an explicit provider is unavailable", async () => {
|
||||
forceNoProvider = true;
|
||||
|
||||
const cfg = createCfg({
|
||||
provider: "openai",
|
||||
minScore: 0.35,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
try {
|
||||
await expect(manager.search("Alpha")).rejects.toThrow(
|
||||
/Memory search unavailable: embedding provider "openai" is configured but unavailable\.[\s\S]*agentId=main purpose=default[\s\S]*registeredMemoryEmbeddingProviders=none/,
|
||||
);
|
||||
await expect(manager.sync({ reason: "test" })).rejects.toThrow(
|
||||
/Memory sync unavailable: embedding provider "openai" is configured but unavailable\./,
|
||||
);
|
||||
forceNoProvider = false;
|
||||
await manager.sync({ reason: "test", force: true });
|
||||
const results = await manager.search("Alpha");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast instead of returning FTS when an explicit provider is lost at runtime", async () => {
|
||||
const cfg = createCfg({
|
||||
provider: "openai",
|
||||
minScore: 0.35,
|
||||
hybrid: { enabled: true },
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
try {
|
||||
await manager.sync({ reason: "test", force: true });
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: null;
|
||||
}
|
||||
).provider = null;
|
||||
|
||||
await expect(manager.search("Alpha")).rejects.toThrow(
|
||||
/Memory search unavailable: embedding provider "openai" is configured but unavailable\./,
|
||||
);
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,927 @@
|
||||
// Memory Core tests cover manager provider lifecycle lease behavior.
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
vi.setConfig({ testTimeout: 240_000 });
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetConfig();
|
||||
});
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embeddedBatchTexts: string[] = [];
|
||||
let embedBatchInputCalls = 0;
|
||||
let providerRuntimeBatchCalls: string[][] = [];
|
||||
let providerRuntimeBatchGate: Promise<void> | null = null;
|
||||
let providerRuntimeBatchErrors: unknown[] = [];
|
||||
let providerRuntimeBatchFailuresRemaining = 0;
|
||||
let providerRuntimeActiveBatchCalls = 0;
|
||||
let providerRuntimeMaxActiveBatchCalls = 0;
|
||||
let providerCloseCalls = 0;
|
||||
let providerCloseFailuresRemaining = 0;
|
||||
let providerCloseFailure: unknown = new Error("provider close failed");
|
||||
let providerCreationFailure: string | null = null;
|
||||
let providerNullResult: string | null = null;
|
||||
let providerCloseGate: Promise<void> | null = null;
|
||||
let providerInitGate: Promise<void> | null = null;
|
||||
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
|
||||
let forceNoProvider = false;
|
||||
|
||||
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
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_EXITED",
|
||||
reason: "exit",
|
||||
exitCode: 134,
|
||||
});
|
||||
}
|
||||
|
||||
function setMemoryIndexStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
|
||||
function restoreMemoryIndexStateDir(): void {
|
||||
if (originalMemoryIndexStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embeddings.js")>();
|
||||
const embedText = (text: string) => {
|
||||
const lower = text.toLowerCase();
|
||||
const alpha = lower.split("alpha").length - 1;
|
||||
const beta = lower.split("beta").length - 1;
|
||||
const image = lower.split("image").length - 1;
|
||||
const audio = lower.split("audio").length - 1;
|
||||
return [alpha, beta, image, audio];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => 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;
|
||||
outputDimensionality?: number;
|
||||
}) => {
|
||||
providerCalls.push({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
});
|
||||
await providerInitGate;
|
||||
if (options.provider === providerCreationFailure) {
|
||||
throw new Error(`provider creation failed: ${options.provider}`);
|
||||
}
|
||||
if (options.provider === providerNullResult) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider,
|
||||
providerUnavailableReason: `provider unavailable: ${options.provider}`,
|
||||
};
|
||||
}
|
||||
if (forceNoProvider) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider ?? "auto",
|
||||
providerUnavailableReason: "No API key found for provider",
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "batch-test" ||
|
||||
options.provider === "batch-wide-test" ||
|
||||
options.provider === identityAliasFixture.provider ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
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: {
|
||||
id: providerId,
|
||||
model,
|
||||
close: async () => {
|
||||
providerCloseCalls += 1;
|
||||
await providerCloseGate;
|
||||
if (providerCloseFailuresRemaining > 0) {
|
||||
providerCloseFailuresRemaining -= 1;
|
||||
throw providerCloseFailure;
|
||||
}
|
||||
},
|
||||
embedQuery: async (text: string) => embedText(text),
|
||||
embedBatch: async (texts: string[]) => {
|
||||
embedBatchCalls += 1;
|
||||
embeddedBatchTexts.push(...texts);
|
||||
return texts.map(embedText);
|
||||
},
|
||||
...(providerId === "gemini" || providerId === "fallback-provider"
|
||||
? {
|
||||
embedBatchInputs: async (
|
||||
inputs: Array<{
|
||||
text: string;
|
||||
parts?: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "inline-data"; mimeType: string; data: string }
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
embedBatchInputCalls += 1;
|
||||
return inputs.map((input) => {
|
||||
const inlineData = input.parts?.find((part) => part.type === "inline-data");
|
||||
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
|
||||
throw new Error("payload too large");
|
||||
}
|
||||
const mimeType =
|
||||
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return [0, 0, 1, 0];
|
||||
}
|
||||
if (mimeType?.startsWith("audio/")) {
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
return embedText(input.text);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(providerId === identityAliasFixture.provider
|
||||
? {
|
||||
runtime: {
|
||||
id: providerId,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.canonicalModel,
|
||||
},
|
||||
indexIdentityAliases: [
|
||||
{
|
||||
model: identityAliasFixture.cacheModel,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.cacheModel,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: providerId === "batch-test" || providerId === "batch-wide-test"
|
||||
? {
|
||||
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 (providerRuntimeBatchErrors.length > 0) {
|
||||
throw providerRuntimeBatchErrors.shift();
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
const managersForCleanup = new Set<MemoryIndexManager>();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
embedBatchCalls = 0;
|
||||
embeddedBatchTexts = [];
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
providerRuntimeBatchGate = null;
|
||||
providerRuntimeBatchErrors = [];
|
||||
providerRuntimeBatchFailuresRemaining = 0;
|
||||
providerRuntimeActiveBatchCalls = 0;
|
||||
providerRuntimeMaxActiveBatchCalls = 0;
|
||||
providerCloseCalls = 0;
|
||||
providerCloseFailuresRemaining = 0;
|
||||
providerCloseFailure = new Error("provider close failed");
|
||||
providerCreationFailure = null;
|
||||
providerNullResult = null;
|
||||
providerCloseGate = null;
|
||||
providerInitGate = null;
|
||||
providerCalls = [];
|
||||
forceNoProvider = false;
|
||||
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
});
|
||||
|
||||
function resetManagerForTest(manager: MemoryIndexManager) {
|
||||
// These tests reuse managers for performance. Clear the index + embedding
|
||||
// cache to keep each test fully isolated.
|
||||
const db = (
|
||||
manager as unknown as {
|
||||
db: {
|
||||
exec: (sql: string) => void;
|
||||
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
|
||||
};
|
||||
}
|
||||
).db;
|
||||
for (const table of [
|
||||
"memory_index_sources",
|
||||
"memory_index_chunks",
|
||||
"memory_embedding_cache",
|
||||
"memory_index_chunks_fts",
|
||||
"memory_index_chunks_vec",
|
||||
]) {
|
||||
const existingTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table);
|
||||
if (existingTable?.name === table) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
}
|
||||
(manager as unknown as { dirty: boolean }).dirty = true;
|
||||
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
|
||||
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
|
||||
}
|
||||
|
||||
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
|
||||
|
||||
function createCfg(params: {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
rememberAcrossConversations?: boolean;
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
batchEnabled?: boolean;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
enabled?: boolean;
|
||||
modalities?: Array<"image" | "audio" | "all">;
|
||||
maxFileBytes?: number;
|
||||
};
|
||||
vectorEnabled?: boolean;
|
||||
cacheEnabled?: boolean;
|
||||
minScore?: number;
|
||||
onSearch?: boolean;
|
||||
hybrid?: {
|
||||
enabled: boolean;
|
||||
vectorWeight?: number;
|
||||
textWeight?: number;
|
||||
temporalDecay?: { enabled: boolean };
|
||||
};
|
||||
}): TestCfg {
|
||||
return isolateMemoryManagerTestConfig({
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: {
|
||||
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
|
||||
},
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
batch: { enabled: true },
|
||||
}
|
||||
: undefined,
|
||||
query: { minScore: params.minScore ?? 0 },
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
rememberAcrossConversations:
|
||||
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function requireManager(
|
||||
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||
missingMessage = "manager missing",
|
||||
): MemoryIndexManager {
|
||||
if (!result.manager) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return result.manager as unknown as MemoryIndexManager;
|
||||
}
|
||||
|
||||
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function getFreshManager(
|
||||
cfg: TestCfg,
|
||||
purpose?: "default" | "status" | "cli",
|
||||
): Promise<MemoryIndexManager> {
|
||||
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
|
||||
managersForCleanup.add(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
it("keeps an active FTS-only generation stable while fallback activates", async () => {
|
||||
const manager = await getFreshManager(
|
||||
createCfg({ provider: "openai", fallback: "fallback-provider" }),
|
||||
"cli",
|
||||
);
|
||||
managersForCleanup.add(manager);
|
||||
type IndexEntry = {
|
||||
path: string;
|
||||
absPath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
hash: string;
|
||||
content: string;
|
||||
};
|
||||
const fields = manager as unknown as {
|
||||
provider: { id: string } | null;
|
||||
providerKey: string;
|
||||
computeProviderKey: () => string;
|
||||
ensureProviderInitialized: () => Promise<void>;
|
||||
markLocalEmbeddingProviderDegraded: (err: unknown) => void;
|
||||
activateFallbackProvider: (reason: string) => Promise<boolean>;
|
||||
beginSyncProviderGeneration: () => void;
|
||||
endSyncProviderGeneration: () => void;
|
||||
indexFile: (
|
||||
entry: IndexEntry,
|
||||
options: { source: "memory"; content: string },
|
||||
) => Promise<void>;
|
||||
db: {
|
||||
prepare: (sql: string) => {
|
||||
get: (...params: unknown[]) => { model?: string } | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
await fields.ensureProviderInitialized();
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.id = "local";
|
||||
fields.providerKey = fields.computeProviderKey();
|
||||
fields.markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
|
||||
await vi.waitFor(() => {
|
||||
expect(fields.provider).toBeNull();
|
||||
expect(providerCloseCalls).toBe(1);
|
||||
});
|
||||
|
||||
const createEntry = (name: string): IndexEntry => {
|
||||
const content = `# Log\n${name} FTS-only generation.`;
|
||||
return {
|
||||
path: `memory/${name}.md`,
|
||||
absPath: path.join(memoryDir, `${name}.md`),
|
||||
mtimeMs: Date.now(),
|
||||
size: Buffer.byteLength(content),
|
||||
hash: hashText(content),
|
||||
content,
|
||||
};
|
||||
};
|
||||
const first = createEntry("fts-first");
|
||||
const second = createEntry("fts-second");
|
||||
|
||||
fields.beginSyncProviderGeneration();
|
||||
try {
|
||||
await fields.indexFile(first, { source: "memory", content: first.content });
|
||||
await expect(fields.activateFallbackProvider("local worker exited")).resolves.toBe(true);
|
||||
await fields.indexFile(second, { source: "memory", content: second.content });
|
||||
} finally {
|
||||
fields.endSyncProviderGeneration();
|
||||
}
|
||||
|
||||
expect(
|
||||
fields.db.prepare("SELECT model FROM memory_index_chunks WHERE path = ?").get(first.path)
|
||||
?.model,
|
||||
).toBe("fts-only");
|
||||
expect(
|
||||
fields.db.prepare("SELECT model FROM memory_index_chunks WHERE path = ?").get(second.path)
|
||||
?.model,
|
||||
).toBe("fts-only");
|
||||
});
|
||||
|
||||
it("waits for admitted provider users before retirement", async () => {
|
||||
const cfg = createCfg({ provider: "openai" });
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
} | null;
|
||||
embedQueryWithRetry: (text: string) => Promise<number[]>;
|
||||
retireCurrentProvider: () => Promise<void>;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
let releaseFirstQuery: () => void = () => {};
|
||||
let markFirstQueryStarted: () => void = () => {};
|
||||
const firstQueryGate = new Promise<void>((resolve) => {
|
||||
releaseFirstQuery = resolve;
|
||||
});
|
||||
const firstQueryStarted = new Promise<void>((resolve) => {
|
||||
markFirstQueryStarted = resolve;
|
||||
});
|
||||
fields.provider.embedQuery = async () => {
|
||||
markFirstQueryStarted();
|
||||
await firstQueryGate;
|
||||
return [1, 0, 0, 0];
|
||||
};
|
||||
|
||||
const queryPromise = fields.embedQueryWithRetry("alpha");
|
||||
await firstQueryStarted;
|
||||
const retirementPromise = fields.retireCurrentProvider();
|
||||
let retirementSettled = false;
|
||||
void retirementPromise.then(
|
||||
() => {
|
||||
retirementSettled = true;
|
||||
},
|
||||
() => {
|
||||
retirementSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(retirementSettled).toBe(false);
|
||||
expect(providerCloseCalls).toBe(0);
|
||||
} finally {
|
||||
releaseFirstQuery();
|
||||
}
|
||||
|
||||
await expect(queryPromise).resolves.toEqual([1, 0, 0, 0]);
|
||||
await retirementPromise;
|
||||
expect(providerCloseCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the leased provider runtime after retirement starts", async () => {
|
||||
const manager = await getPersistentManager(createCfg({ provider: "openai" }));
|
||||
type QueryProvider = {
|
||||
embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise<number[]>;
|
||||
};
|
||||
const fields = manager as unknown as {
|
||||
provider: QueryProvider | null;
|
||||
providerRuntime?: { inlineQueryTimeoutMs?: number };
|
||||
acquireProviderUse: (provider: QueryProvider) => () => void;
|
||||
retireCurrentProvider: () => Promise<void>;
|
||||
embedQueryWithRetry: (
|
||||
text: string,
|
||||
signal: AbortSignal | undefined,
|
||||
provider: QueryProvider,
|
||||
markDegraded: boolean,
|
||||
providerRuntime: { inlineQueryTimeoutMs?: number },
|
||||
) => Promise<number[]>;
|
||||
};
|
||||
await manager.probeEmbeddingAvailability();
|
||||
const provider = fields.provider;
|
||||
if (!provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
const providerRuntime = { inlineQueryTimeoutMs: 10 };
|
||||
fields.providerRuntime = providerRuntime;
|
||||
provider.embedQuery = async (_text, options) =>
|
||||
await new Promise<number[]>((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve([1, 0, 0, 0]), 100);
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
const reason = options.signal?.reason;
|
||||
reject(reason instanceof Error ? reason : new Error("embedding aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
const releaseProvider = fields.acquireProviderUse(provider);
|
||||
const retirementPromise = fields.retireCurrentProvider();
|
||||
try {
|
||||
await vi.waitFor(() => expect(fields.provider).toBeNull());
|
||||
await expect(
|
||||
fields.embedQueryWithRetry("alpha", undefined, provider, false, providerRuntime),
|
||||
).rejects.toThrow("timed out");
|
||||
expect(providerCloseCalls).toBe(0);
|
||||
} finally {
|
||||
releaseProvider();
|
||||
}
|
||||
|
||||
await retirementPromise;
|
||||
expect(providerCloseCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("waits for an admitted search before manager teardown", async () => {
|
||||
const manager = await getPersistentManager(createCfg({ provider: "openai" }));
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
searchVector: () => Promise<unknown[]>;
|
||||
closing: boolean;
|
||||
closed: boolean;
|
||||
};
|
||||
let releaseVectorSearch: () => void = () => {};
|
||||
let markVectorSearchStarted: () => void = () => {};
|
||||
const vectorSearchGate = new Promise<void>((resolve) => {
|
||||
releaseVectorSearch = resolve;
|
||||
});
|
||||
const vectorSearchStarted = new Promise<void>((resolve) => {
|
||||
markVectorSearchStarted = resolve;
|
||||
});
|
||||
fields.searchVector = async () => {
|
||||
markVectorSearchStarted();
|
||||
await vectorSearchGate;
|
||||
return [];
|
||||
};
|
||||
|
||||
const searchPromise = manager.search("alpha");
|
||||
await vectorSearchStarted;
|
||||
const closePromise = manager.close();
|
||||
let closeSettled = false;
|
||||
void closePromise.then(
|
||||
() => {
|
||||
closeSettled = true;
|
||||
},
|
||||
() => {
|
||||
closeSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(closeSettled).toBe(false);
|
||||
expect(fields.closing).toBe(true);
|
||||
expect(fields.closed).toBe(false);
|
||||
expect(providerCloseCalls).toBe(0);
|
||||
} finally {
|
||||
releaseVectorSearch();
|
||||
}
|
||||
|
||||
await expect(searchPromise).resolves.toBeDefined();
|
||||
await closePromise;
|
||||
expect(providerCloseCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("waits for an admitted vector probe before manager teardown", async () => {
|
||||
const manager = await getPersistentManager(createCfg({ provider: "openai" }));
|
||||
const fields = manager as unknown as {
|
||||
ensureVectorReady: () => Promise<boolean>;
|
||||
};
|
||||
let releaseProbe: () => void = () => {};
|
||||
let markProbeStarted: () => void = () => {};
|
||||
const probeGate = new Promise<void>((resolve) => {
|
||||
releaseProbe = resolve;
|
||||
});
|
||||
const probeStarted = new Promise<void>((resolve) => {
|
||||
markProbeStarted = resolve;
|
||||
});
|
||||
fields.ensureVectorReady = async () => {
|
||||
markProbeStarted();
|
||||
await probeGate;
|
||||
return true;
|
||||
};
|
||||
|
||||
const probePromise = manager.probeVectorAvailability();
|
||||
await probeStarted;
|
||||
const closePromise = manager.close();
|
||||
let closeSettled = false;
|
||||
void closePromise.then(
|
||||
() => {
|
||||
closeSettled = true;
|
||||
},
|
||||
() => {
|
||||
closeSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(closeSettled).toBe(false);
|
||||
expect(providerCloseCalls).toBe(0);
|
||||
} finally {
|
||||
releaseProbe();
|
||||
}
|
||||
|
||||
await expect(probePromise).resolves.toBe(true);
|
||||
await closePromise;
|
||||
expect(providerCloseCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("fails closed when fallback initialization fails for an explicit provider", async () => {
|
||||
const cfg = createCfg({
|
||||
provider: "openai",
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
} | null;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.embedQuery = async () => {
|
||||
throw new Error("embedding provider failed");
|
||||
};
|
||||
providerCreationFailure = "fallback-provider";
|
||||
|
||||
await expect(manager.search("alpha")).rejects.toThrow(
|
||||
/Memory search unavailable: embedding provider "openai" is configured but unavailable\./,
|
||||
);
|
||||
|
||||
providerCreationFailure = null;
|
||||
await expect(manager.search("alpha")).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("retries the optional primary after fallback initialization fails", async () => {
|
||||
const cfg = createCfg({
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
} | null;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.embedQuery = async () => {
|
||||
throw new Error("embedding provider failed");
|
||||
};
|
||||
providerCreationFailure = "fallback-provider";
|
||||
const callsBeforeSearch = providerCalls.length;
|
||||
|
||||
await expect(manager.search("alpha")).resolves.toBeDefined();
|
||||
|
||||
providerCreationFailure = null;
|
||||
await expect(manager.search("alpha")).resolves.toBeDefined();
|
||||
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toEqual([
|
||||
"fallback-provider",
|
||||
"openai",
|
||||
]);
|
||||
expect(fields.provider?.id).toBe("mock");
|
||||
});
|
||||
|
||||
it("fails closed and retries a required primary after a null fallback result", async () => {
|
||||
const cfg = createCfg({
|
||||
provider: "openai",
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
provider: { embedQuery: (text: string) => Promise<number[]> } | null;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.embedQuery = async () => {
|
||||
throw new Error("embedding provider failed");
|
||||
};
|
||||
providerNullResult = "fallback-provider";
|
||||
|
||||
await expect(manager.search("alpha")).rejects.toThrow(
|
||||
/Memory search unavailable: embedding provider "openai" is configured but unavailable\./,
|
||||
);
|
||||
|
||||
providerNullResult = null;
|
||||
await expect(manager.search("alpha")).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("retries an optional primary after a null fallback result", async () => {
|
||||
const cfg = createCfg({
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
provider: { id: string; embedQuery: (text: string) => Promise<number[]> } | null;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.embedQuery = async () => {
|
||||
throw new Error("embedding provider failed");
|
||||
};
|
||||
providerNullResult = "fallback-provider";
|
||||
|
||||
await expect(manager.search("alpha")).resolves.toBeDefined();
|
||||
|
||||
providerNullResult = null;
|
||||
await expect(manager.search("alpha")).resolves.toBeDefined();
|
||||
expect(fields.provider?.id).toBe("mock");
|
||||
});
|
||||
|
||||
it("keeps concurrent optional searches in FTS mode when shared fallback fails", async () => {
|
||||
const cfg = createCfg({
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
} | null;
|
||||
ensureProviderInitialized: () => Promise<void>;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.embedQuery = async () => {
|
||||
throw new Error("embedding provider failed");
|
||||
};
|
||||
const ensureProviderInitialized = fields.ensureProviderInitialized.bind(manager);
|
||||
let providerInitializationCalls = 0;
|
||||
fields.ensureProviderInitialized = async () => {
|
||||
providerInitializationCalls += 1;
|
||||
await ensureProviderInitialized();
|
||||
};
|
||||
providerCreationFailure = "fallback-provider";
|
||||
let releaseProviderInit: () => void = () => {};
|
||||
providerInitGate = new Promise<void>((resolve) => {
|
||||
releaseProviderInit = resolve;
|
||||
});
|
||||
|
||||
const callsBeforeSearch = providerCalls.length;
|
||||
const firstSearch = manager.search("alpha");
|
||||
await vi.waitFor(() =>
|
||||
expect(providerCalls.some((call) => call.provider === "fallback-provider")).toBe(true),
|
||||
);
|
||||
const initializationCallsBeforeSecondSearch = providerInitializationCalls;
|
||||
const secondSearch = manager.search("zebra");
|
||||
let secondSettled = false;
|
||||
void secondSearch.then(
|
||||
() => {
|
||||
secondSettled = true;
|
||||
},
|
||||
() => {
|
||||
secondSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() =>
|
||||
expect(providerInitializationCalls).toBeGreaterThan(initializationCallsBeforeSecondSearch),
|
||||
);
|
||||
expect(secondSettled).toBe(false);
|
||||
releaseProviderInit();
|
||||
const results = await Promise.all([firstSearch, secondSearch]);
|
||||
expect(results.every((result) => result.length > 0)).toBe(true);
|
||||
expect(
|
||||
providerCalls
|
||||
.slice(callsBeforeSearch)
|
||||
.filter((call) => call.provider === "fallback-provider"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
providerInitGate = null;
|
||||
releaseProviderInit();
|
||||
await Promise.allSettled([firstSearch, secondSearch]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,869 @@
|
||||
// Memory Core tests cover manager provider lifecycle availability behavior.
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
vi.setConfig({ testTimeout: 240_000 });
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetConfig();
|
||||
});
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embeddedBatchTexts: string[] = [];
|
||||
let embedBatchInputCalls = 0;
|
||||
let providerRuntimeBatchCalls: string[][] = [];
|
||||
let providerRuntimeBatchGate: Promise<void> | null = null;
|
||||
let providerRuntimeBatchErrors: unknown[] = [];
|
||||
let providerRuntimeBatchFailuresRemaining = 0;
|
||||
let providerRuntimeActiveBatchCalls = 0;
|
||||
let providerRuntimeMaxActiveBatchCalls = 0;
|
||||
let providerCloseCalls = 0;
|
||||
let providerCloseFailuresRemaining = 0;
|
||||
let providerCloseFailure: unknown = new Error("provider close failed");
|
||||
let providerCreationFailure: string | null = null;
|
||||
let providerNullResult: string | null = null;
|
||||
let providerCloseGate: Promise<void> | null = null;
|
||||
let providerInitGate: Promise<void> | null = null;
|
||||
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
|
||||
let forceNoProvider = false;
|
||||
|
||||
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
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_EXITED",
|
||||
reason: "exit",
|
||||
exitCode: 134,
|
||||
});
|
||||
}
|
||||
|
||||
function setMemoryIndexStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
|
||||
function restoreMemoryIndexStateDir(): void {
|
||||
if (originalMemoryIndexStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embeddings.js")>();
|
||||
const embedText = (text: string) => {
|
||||
const lower = text.toLowerCase();
|
||||
const alpha = lower.split("alpha").length - 1;
|
||||
const beta = lower.split("beta").length - 1;
|
||||
const image = lower.split("image").length - 1;
|
||||
const audio = lower.split("audio").length - 1;
|
||||
return [alpha, beta, image, audio];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => 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;
|
||||
outputDimensionality?: number;
|
||||
}) => {
|
||||
providerCalls.push({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
});
|
||||
await providerInitGate;
|
||||
if (options.provider === providerCreationFailure) {
|
||||
throw new Error(`provider creation failed: ${options.provider}`);
|
||||
}
|
||||
if (options.provider === providerNullResult) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider,
|
||||
providerUnavailableReason: `provider unavailable: ${options.provider}`,
|
||||
};
|
||||
}
|
||||
if (forceNoProvider) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider ?? "auto",
|
||||
providerUnavailableReason: "No API key found for provider",
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "batch-test" ||
|
||||
options.provider === "batch-wide-test" ||
|
||||
options.provider === identityAliasFixture.provider ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
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: {
|
||||
id: providerId,
|
||||
model,
|
||||
close: async () => {
|
||||
providerCloseCalls += 1;
|
||||
await providerCloseGate;
|
||||
if (providerCloseFailuresRemaining > 0) {
|
||||
providerCloseFailuresRemaining -= 1;
|
||||
throw providerCloseFailure;
|
||||
}
|
||||
},
|
||||
embedQuery: async (text: string) => embedText(text),
|
||||
embedBatch: async (texts: string[]) => {
|
||||
embedBatchCalls += 1;
|
||||
embeddedBatchTexts.push(...texts);
|
||||
return texts.map(embedText);
|
||||
},
|
||||
...(providerId === "gemini" || providerId === "fallback-provider"
|
||||
? {
|
||||
embedBatchInputs: async (
|
||||
inputs: Array<{
|
||||
text: string;
|
||||
parts?: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "inline-data"; mimeType: string; data: string }
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
embedBatchInputCalls += 1;
|
||||
return inputs.map((input) => {
|
||||
const inlineData = input.parts?.find((part) => part.type === "inline-data");
|
||||
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
|
||||
throw new Error("payload too large");
|
||||
}
|
||||
const mimeType =
|
||||
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return [0, 0, 1, 0];
|
||||
}
|
||||
if (mimeType?.startsWith("audio/")) {
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
return embedText(input.text);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(providerId === identityAliasFixture.provider
|
||||
? {
|
||||
runtime: {
|
||||
id: providerId,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.canonicalModel,
|
||||
},
|
||||
indexIdentityAliases: [
|
||||
{
|
||||
model: identityAliasFixture.cacheModel,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.cacheModel,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: providerId === "batch-test" || providerId === "batch-wide-test"
|
||||
? {
|
||||
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 (providerRuntimeBatchErrors.length > 0) {
|
||||
throw providerRuntimeBatchErrors.shift();
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
const managersForCleanup = new Set<MemoryIndexManager>();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
embedBatchCalls = 0;
|
||||
embeddedBatchTexts = [];
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
providerRuntimeBatchGate = null;
|
||||
providerRuntimeBatchErrors = [];
|
||||
providerRuntimeBatchFailuresRemaining = 0;
|
||||
providerRuntimeActiveBatchCalls = 0;
|
||||
providerRuntimeMaxActiveBatchCalls = 0;
|
||||
providerCloseCalls = 0;
|
||||
providerCloseFailuresRemaining = 0;
|
||||
providerCloseFailure = new Error("provider close failed");
|
||||
providerCreationFailure = null;
|
||||
providerNullResult = null;
|
||||
providerCloseGate = null;
|
||||
providerInitGate = null;
|
||||
providerCalls = [];
|
||||
forceNoProvider = false;
|
||||
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
});
|
||||
|
||||
function resetManagerForTest(manager: MemoryIndexManager) {
|
||||
// These tests reuse managers for performance. Clear the index + embedding
|
||||
// cache to keep each test fully isolated.
|
||||
const db = (
|
||||
manager as unknown as {
|
||||
db: {
|
||||
exec: (sql: string) => void;
|
||||
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
|
||||
};
|
||||
}
|
||||
).db;
|
||||
for (const table of [
|
||||
"memory_index_sources",
|
||||
"memory_index_chunks",
|
||||
"memory_embedding_cache",
|
||||
"memory_index_chunks_fts",
|
||||
"memory_index_chunks_vec",
|
||||
]) {
|
||||
const existingTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table);
|
||||
if (existingTable?.name === table) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
}
|
||||
(manager as unknown as { dirty: boolean }).dirty = true;
|
||||
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
|
||||
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
|
||||
}
|
||||
|
||||
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
|
||||
|
||||
function createCfg(params: {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
rememberAcrossConversations?: boolean;
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
batchEnabled?: boolean;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
enabled?: boolean;
|
||||
modalities?: Array<"image" | "audio" | "all">;
|
||||
maxFileBytes?: number;
|
||||
};
|
||||
vectorEnabled?: boolean;
|
||||
cacheEnabled?: boolean;
|
||||
minScore?: number;
|
||||
onSearch?: boolean;
|
||||
hybrid?: {
|
||||
enabled: boolean;
|
||||
vectorWeight?: number;
|
||||
textWeight?: number;
|
||||
temporalDecay?: { enabled: boolean };
|
||||
};
|
||||
}): TestCfg {
|
||||
return isolateMemoryManagerTestConfig({
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: {
|
||||
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
|
||||
},
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
batch: { enabled: true },
|
||||
}
|
||||
: undefined,
|
||||
query: { minScore: params.minScore ?? 0 },
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
rememberAcrossConversations:
|
||||
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function requireManager(
|
||||
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||
missingMessage = "manager missing",
|
||||
): MemoryIndexManager {
|
||||
if (!result.manager) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return result.manager as unknown as MemoryIndexManager;
|
||||
}
|
||||
|
||||
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function getFreshManager(
|
||||
cfg: TestCfg,
|
||||
purpose?: "default" | "status" | "cli",
|
||||
): Promise<MemoryIndexManager> {
|
||||
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
|
||||
managersForCleanup.add(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
it("caches embedding probe readiness across transient status managers", async () => {
|
||||
const cfg = createCfg({});
|
||||
const first = requireManager(
|
||||
await getMemorySearchManager({ cfg, agentId: "main", purpose: "status" }),
|
||||
);
|
||||
managersForCleanup.add(first);
|
||||
|
||||
await expect(first.probeEmbeddingAvailability()).resolves.toEqual({ ok: true });
|
||||
expect(embedBatchCalls).toBe(1);
|
||||
await first.close();
|
||||
|
||||
const second = requireManager(
|
||||
await getMemorySearchManager({ cfg, agentId: "main", purpose: "status" }),
|
||||
);
|
||||
managersForCleanup.add(second);
|
||||
|
||||
const cachedBeforeProbe = second.getCachedEmbeddingAvailability?.();
|
||||
expect(cachedBeforeProbe?.ok).toBe(true);
|
||||
expect(cachedBeforeProbe?.checked).toBe(true);
|
||||
expect(cachedBeforeProbe?.cached).toBe(true);
|
||||
expect(cachedBeforeProbe?.checkedAtMs).toBeTypeOf("number");
|
||||
expect(cachedBeforeProbe?.cacheExpiresAtMs).toBeTypeOf("number");
|
||||
if (
|
||||
typeof cachedBeforeProbe?.checkedAtMs === "number" &&
|
||||
typeof cachedBeforeProbe.cacheExpiresAtMs === "number"
|
||||
) {
|
||||
expect(cachedBeforeProbe.cacheExpiresAtMs - cachedBeforeProbe.checkedAtMs).toBe(30_000);
|
||||
}
|
||||
await expect(second.probeEmbeddingAvailability()).resolves.toStrictEqual({
|
||||
ok: true,
|
||||
checked: true,
|
||||
cached: true,
|
||||
checkedAtMs: cachedBeforeProbe?.checkedAtMs,
|
||||
cacheExpiresAtMs: cachedBeforeProbe?.cacheExpiresAtMs,
|
||||
});
|
||||
expect(embedBatchCalls).toBe(1);
|
||||
|
||||
const cached = second.getCachedEmbeddingAvailability?.();
|
||||
expect((cached?.cacheExpiresAtMs ?? 0) - (cached?.checkedAtMs ?? 0)).toBe(30_000);
|
||||
});
|
||||
|
||||
it("clears cached embedding probe readiness when local embeddings degrade", async () => {
|
||||
const cfg = createCfg({});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
|
||||
await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({ ok: true });
|
||||
expect(manager.getCachedEmbeddingAvailability()?.ok).toBe(true);
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
).provider = {
|
||||
id: "local",
|
||||
model: "local-model",
|
||||
embedQuery: async () => [1, 0],
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [1, 0]),
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
(
|
||||
manager as unknown as {
|
||||
markLocalEmbeddingProviderDegraded: (err: unknown) => void;
|
||||
}
|
||||
).markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
|
||||
|
||||
expect(manager.getCachedEmbeddingAvailability()).toBeNull();
|
||||
await expect(manager.probeEmbeddingAvailability()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining("Local embeddings degraded"),
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for degraded provider shutdown before fallback initialization", async () => {
|
||||
const cfg = createCfg({ fallback: "fallback-provider" });
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
} | null;
|
||||
markLocalEmbeddingProviderDegraded: (err: unknown) => void;
|
||||
activateFallbackProvider: (reason: string) => Promise<boolean>;
|
||||
withTimeout: <T>(promise: Promise<T>, timeoutMs: number, message: string) => Promise<T>;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.id = "local";
|
||||
fields.markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
|
||||
const callsBeforeFallback = providerCalls.length;
|
||||
const fallbackPromise = fields.activateFallbackProvider("local worker exited");
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(providerCalls).toHaveLength(callsBeforeFallback);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
await fallbackPromise;
|
||||
}
|
||||
expect(providerCalls.slice(callsBeforeFallback).map((call) => call.provider)).toEqual([
|
||||
"fallback-provider",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries failed provider retirement before fallback initialization", async () => {
|
||||
const cfg = createCfg({ fallback: "fallback-provider" });
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
providerCloseFailuresRemaining = 1;
|
||||
const fields = manager as unknown as {
|
||||
activateFallbackProvider: (reason: string) => Promise<boolean>;
|
||||
};
|
||||
const callsBeforeFallback = providerCalls.length;
|
||||
|
||||
await expect(fields.activateFallbackProvider("provider failed")).rejects.toThrow(
|
||||
"provider close failed",
|
||||
);
|
||||
expect(providerCalls).toHaveLength(callsBeforeFallback);
|
||||
|
||||
await expect(fields.activateFallbackProvider("provider failed")).resolves.toBe(true);
|
||||
expect(providerCloseCalls).toBe(2);
|
||||
expect(providerCalls.slice(callsBeforeFallback).map((call) => call.provider)).toEqual([
|
||||
"fallback-provider",
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for provider shutdown before retry initialization", async () => {
|
||||
const cfg = createCfg({ provider: "openai" });
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
(
|
||||
manager as unknown as {
|
||||
resetProviderInitializationForRetry: () => void;
|
||||
}
|
||||
).resetProviderInitializationForRetry();
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
|
||||
const callsBeforeProbe = providerCalls.length;
|
||||
const probePromise = manager.probeEmbeddingAvailability();
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(providerCalls).toHaveLength(callsBeforeProbe);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
await probePromise;
|
||||
}
|
||||
expect(providerCalls.slice(callsBeforeProbe).map((call) => call.provider)).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("waits for active provider shutdown before fallback initialization", async () => {
|
||||
const cfg = createCfg({
|
||||
provider: "openai",
|
||||
fallback: "fallback-provider",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
} | null;
|
||||
};
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
fields.provider.embedQuery = async () => {
|
||||
throw new Error("embedding provider failed");
|
||||
};
|
||||
|
||||
const callsBeforeSearch = providerCalls.length;
|
||||
const searchPromise = manager.search("alpha");
|
||||
let concurrentSearch: ReturnType<typeof manager.search> = Promise.resolve([]);
|
||||
try {
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
concurrentSearch = manager.search("zebra");
|
||||
let concurrentSettled = false;
|
||||
void concurrentSearch.then(
|
||||
() => {
|
||||
concurrentSettled = true;
|
||||
},
|
||||
() => {
|
||||
concurrentSettled = true;
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(concurrentSettled).toBe(false);
|
||||
expect(providerCalls).toHaveLength(callsBeforeSearch);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
await Promise.allSettled([searchPromise, concurrentSearch]);
|
||||
}
|
||||
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toEqual([
|
||||
"fallback-provider",
|
||||
]);
|
||||
await expect(concurrentSearch).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("leases the indexing provider generation through chunk publication", async () => {
|
||||
const manager = await getFreshManager(
|
||||
createCfg({
|
||||
provider: "openai",
|
||||
fallback: "fallback-provider",
|
||||
cacheEnabled: true,
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
}),
|
||||
"cli",
|
||||
);
|
||||
managersForCleanup.add(manager);
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
} | null;
|
||||
providerKey: string;
|
||||
computeProviderKey: () => string;
|
||||
ensureProviderInitialized: () => Promise<void>;
|
||||
markLocalEmbeddingProviderDegraded: (err: unknown) => void;
|
||||
activateFallbackProvider: (reason: string) => Promise<boolean>;
|
||||
withTimeout: <T>(promise: Promise<T>, timeoutMs: number, message: string) => Promise<T>;
|
||||
indexFile: (
|
||||
entry: {
|
||||
path: string;
|
||||
absPath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
hash: string;
|
||||
content: string;
|
||||
},
|
||||
options: { source: "memory"; content: string },
|
||||
) => Promise<void>;
|
||||
ensureVectorReady: (dimensions?: number) => Promise<boolean>;
|
||||
db: {
|
||||
prepare: (sql: string) => {
|
||||
get: (
|
||||
...params: unknown[]
|
||||
) => { model?: string; provider?: string; provider_key?: string } | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
await fields.ensureProviderInitialized();
|
||||
if (!fields.provider) {
|
||||
throw new Error("Expected a test embedding provider");
|
||||
}
|
||||
const indexedProvider = fields.provider;
|
||||
indexedProvider.id = "local";
|
||||
fields.providerKey = fields.computeProviderKey();
|
||||
const indexedProviderKey = fields.providerKey;
|
||||
const firstContent = "# Log\nFirst memory line indexed during provider fallback.";
|
||||
const secondContent = "# Log\nSecond memory line indexed during provider fallback.";
|
||||
|
||||
let releaseFirstEmbedding: () => void = () => {};
|
||||
let releaseSecondEmbedding: () => void = () => {};
|
||||
let markFirstEmbeddingStarted: () => void = () => {};
|
||||
let markSecondEmbeddingStarted: () => void = () => {};
|
||||
const firstEmbeddingGate = new Promise<void>((resolve) => {
|
||||
releaseFirstEmbedding = resolve;
|
||||
});
|
||||
const secondEmbeddingGate = new Promise<void>((resolve) => {
|
||||
releaseSecondEmbedding = resolve;
|
||||
});
|
||||
const firstEmbeddingStarted = new Promise<void>((resolve) => {
|
||||
markFirstEmbeddingStarted = resolve;
|
||||
});
|
||||
const secondEmbeddingStarted = new Promise<void>((resolve) => {
|
||||
markSecondEmbeddingStarted = resolve;
|
||||
});
|
||||
indexedProvider.embedBatch = async (texts) => {
|
||||
if (texts.some((text) => text.includes("First"))) {
|
||||
markFirstEmbeddingStarted();
|
||||
await firstEmbeddingGate;
|
||||
} else {
|
||||
markSecondEmbeddingStarted();
|
||||
await secondEmbeddingGate;
|
||||
}
|
||||
return texts.map(() => [1, 0, 0, 0]);
|
||||
};
|
||||
let releasePublication: () => void = () => {};
|
||||
let markPublicationStarted: () => void = () => {};
|
||||
const publicationGate = new Promise<void>((resolve) => {
|
||||
releasePublication = resolve;
|
||||
});
|
||||
const publicationStarted = new Promise<void>((resolve) => {
|
||||
markPublicationStarted = resolve;
|
||||
});
|
||||
const ensureVectorReady = fields.ensureVectorReady.bind(manager);
|
||||
let publicationCalls = 0;
|
||||
fields.ensureVectorReady = async (dimensions) => {
|
||||
publicationCalls += 1;
|
||||
if (publicationCalls === 1) {
|
||||
return await ensureVectorReady(dimensions);
|
||||
}
|
||||
markPublicationStarted();
|
||||
await publicationGate;
|
||||
return await ensureVectorReady(dimensions);
|
||||
};
|
||||
|
||||
const callsBeforeFallback = providerCalls.length;
|
||||
const firstIndexPromise = fields.indexFile(
|
||||
{
|
||||
path: "memory/generation-race-first.md",
|
||||
absPath: path.join(memoryDir, "generation-race-first.md"),
|
||||
mtimeMs: Date.now(),
|
||||
size: Buffer.byteLength(firstContent),
|
||||
hash: hashText(firstContent),
|
||||
content: firstContent,
|
||||
},
|
||||
{ source: "memory", content: firstContent },
|
||||
);
|
||||
const secondIndexPromise = fields.indexFile(
|
||||
{
|
||||
path: "memory/generation-race-second.md",
|
||||
absPath: path.join(memoryDir, "generation-race-second.md"),
|
||||
mtimeMs: Date.now(),
|
||||
size: Buffer.byteLength(secondContent),
|
||||
hash: hashText(secondContent),
|
||||
content: secondContent,
|
||||
},
|
||||
{ source: "memory", content: secondContent },
|
||||
);
|
||||
let fallbackPromise: Promise<boolean> | null = null;
|
||||
try {
|
||||
await fields.withTimeout(
|
||||
Promise.all([firstEmbeddingStarted, secondEmbeddingStarted]),
|
||||
5_000,
|
||||
"concurrent embeddings did not start",
|
||||
);
|
||||
fields.markLocalEmbeddingProviderDegraded(createLocalWorkerExitError());
|
||||
await vi.waitFor(() => expect(fields.provider).toBeNull());
|
||||
fallbackPromise = fields.activateFallbackProvider("local worker exited");
|
||||
releaseFirstEmbedding();
|
||||
await firstIndexPromise;
|
||||
expect(providerCloseCalls).toBe(0);
|
||||
expect(providerCalls).toHaveLength(callsBeforeFallback);
|
||||
|
||||
releaseSecondEmbedding();
|
||||
await fields.withTimeout(publicationStarted, 5_000, "publication did not start");
|
||||
expect(providerCloseCalls).toBe(0);
|
||||
expect(providerCalls).toHaveLength(callsBeforeFallback);
|
||||
|
||||
releasePublication();
|
||||
await secondIndexPromise;
|
||||
await expect(fallbackPromise).resolves.toBe(true);
|
||||
} finally {
|
||||
releaseFirstEmbedding();
|
||||
releaseSecondEmbedding();
|
||||
releasePublication();
|
||||
await Promise.allSettled([
|
||||
firstIndexPromise,
|
||||
secondIndexPromise,
|
||||
...(fallbackPromise ? [fallbackPromise] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
expect(providerCalls.slice(callsBeforeFallback).map((call) => call.provider)).toEqual([
|
||||
"fallback-provider",
|
||||
]);
|
||||
expect(
|
||||
fields.db
|
||||
.prepare("SELECT model FROM memory_index_chunks WHERE path = ?")
|
||||
.get("memory/generation-race-second.md")?.model,
|
||||
).toBe(indexedProvider.model);
|
||||
expect(
|
||||
fields.db
|
||||
.prepare("SELECT provider, model, provider_key FROM memory_embedding_cache LIMIT 1")
|
||||
.get(),
|
||||
).toEqual({
|
||||
provider: indexedProvider.id,
|
||||
model: indexedProvider.model,
|
||||
provider_key: indexedProviderKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
// Memory Core plugin module owns embedding provider lifecycle.
|
||||
import { resolveAgentConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
formatErrorMessage,
|
||||
readErrorName,
|
||||
toErrorObject,
|
||||
} from "openclaw/plugin-sdk/error-runtime";
|
||||
import { listRegisteredMemoryEmbeddingProviderAdapters } from "openclaw/plugin-sdk/memory-core-host-embedding-registry";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
resolveAgentDir,
|
||||
type OpenClawConfig,
|
||||
type ResolvedMemorySearchConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import type {
|
||||
MemoryEmbeddingProbeResult,
|
||||
MemorySearchRuntimeDebug,
|
||||
MemorySyncParams,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
createEmbeddingProvider,
|
||||
resolveEmbeddingProviderAdapterTransport,
|
||||
type EmbeddingProvider,
|
||||
type EmbeddingProviderRequest,
|
||||
type EmbeddingProviderResult,
|
||||
} from "./embeddings.js";
|
||||
import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js";
|
||||
import { isLocalEmbeddingWorkerFailure } from "./manager-local-worker-errors.js";
|
||||
import {
|
||||
createDegradedMemoryProviderLifecycle,
|
||||
createPendingMemoryProviderLifecycle,
|
||||
resolveMemoryPrimaryProviderRequest,
|
||||
resolveMemoryProviderState,
|
||||
} from "./manager-provider-state.js";
|
||||
import type { MemoryIndexIdentityState } from "./manager-reindex-state.js";
|
||||
|
||||
const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000;
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
export type MemoryEmbeddingProviderRequirement = {
|
||||
mode: "fts-only" | "optional" | "required";
|
||||
provider: string;
|
||||
configuredProvider?: string;
|
||||
};
|
||||
export type MemoryEmbeddingBootstrapDebug = NonNullable<
|
||||
MemorySearchRuntimeDebug["embeddingBootstrap"]
|
||||
>;
|
||||
type EmbeddingProbeCacheEntry = {
|
||||
result: MemoryEmbeddingProbeResult;
|
||||
checkedAtMs: number;
|
||||
expireAtMs: number;
|
||||
};
|
||||
const EMBEDDING_PROBE_CACHE = new Map<string, EmbeddingProbeCacheEntry>();
|
||||
|
||||
export function clearMemoryEmbeddingProbeCache(): void {
|
||||
EMBEDDING_PROBE_CACHE.clear();
|
||||
}
|
||||
|
||||
export function resolveEffectiveMemorySearchSettings(
|
||||
settings: ResolvedMemorySearchConfig,
|
||||
): ResolvedMemorySearchConfig {
|
||||
if (settings.provider !== "none" || !settings.store.vector.enabled) {
|
||||
return settings;
|
||||
}
|
||||
return {
|
||||
...settings,
|
||||
store: {
|
||||
...settings.store,
|
||||
vector: {
|
||||
...settings.store.vector,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredMemoryEmbeddingProvider(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
}): string | undefined {
|
||||
const agentEntry = resolveAgentConfig(params.cfg, normalizeAgentId(params.agentId));
|
||||
return agentEntry?.memory?.search?.provider ?? params.cfg.memory?.search?.provider;
|
||||
}
|
||||
|
||||
export function resolveMemoryEmbeddingProviderRequirement(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
settings: ResolvedMemorySearchConfig;
|
||||
}): MemoryEmbeddingProviderRequirement {
|
||||
const configuredProvider = resolveConfiguredMemoryEmbeddingProvider(params)?.trim();
|
||||
if (params.settings.provider === "none" || configuredProvider === "none") {
|
||||
return { mode: "fts-only", provider: params.settings.provider };
|
||||
}
|
||||
const adapterTransport = resolveEmbeddingProviderAdapterTransport(
|
||||
params.settings.provider,
|
||||
params.cfg,
|
||||
);
|
||||
if (!configuredProvider || configuredProvider === "auto" || adapterTransport === "local") {
|
||||
return { mode: "optional", provider: params.settings.provider };
|
||||
}
|
||||
return {
|
||||
mode: "required",
|
||||
provider: params.settings.provider,
|
||||
configuredProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class MemoryProviderLifecycle extends MemoryManagerEmbeddingOps {
|
||||
protected abstract readonly cacheKey: string;
|
||||
protected abstract readonly purpose: "default" | "status" | "cli";
|
||||
protected abstract readonly providerRequirement: MemoryEmbeddingProviderRequirement;
|
||||
protected abstract readonly requestedProvider: EmbeddingProviderRequest;
|
||||
protected abstract providerInitPromise: Promise<void> | null;
|
||||
protected abstract providerInitialized: boolean;
|
||||
protected abstract embeddingBootstrapFailure?: MemoryEmbeddingBootstrapDebug;
|
||||
protected abstract providerRetirementPromise: Promise<void>;
|
||||
protected abstract providersPendingRetirement: Set<EmbeddingProvider>;
|
||||
protected abstract closing: boolean;
|
||||
protected abstract activeManagerOperations: number;
|
||||
protected abstract managerIdleWaiters: Set<() => void>;
|
||||
protected abstract indexIdentityDirty: boolean;
|
||||
protected abstract indexIdentityState: MemoryIndexIdentityState;
|
||||
protected abstract syncAdmitted(
|
||||
params?: MemorySyncParams,
|
||||
options?: { allowEmbeddingBootstrapFallback?: boolean; queuedSessionOwner?: boolean },
|
||||
): Promise<void>;
|
||||
|
||||
protected applyProviderResult(providerResult: EmbeddingProviderResult): void {
|
||||
const providerState = resolveMemoryProviderState(providerResult);
|
||||
this.provider = providerState.provider;
|
||||
this.fallbackFrom = providerState.fallbackFrom;
|
||||
this.fallbackReason = providerState.fallbackReason;
|
||||
this.providerUnavailableReason = providerState.providerUnavailableReason;
|
||||
this.providerLifecycle = providerState.lifecycle;
|
||||
this.providerRuntime = providerState.providerRuntime;
|
||||
this.providerInitialized = true;
|
||||
}
|
||||
|
||||
protected markEmbeddingBootstrapFailure(
|
||||
err: unknown,
|
||||
options?: { retainProvider?: boolean; provider?: string },
|
||||
): MemoryEmbeddingBootstrapDebug {
|
||||
const rawErrorName = readErrorName(err).trim();
|
||||
const errorName = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(rawErrorName) ? rawErrorName : "";
|
||||
const message =
|
||||
redactSensitiveText(formatErrorMessage(err), { mode: "tools" }).trim() ||
|
||||
"embedding provider initialization failed";
|
||||
const reason = redactSensitiveText(
|
||||
errorName && errorName !== "Error" ? `${errorName}: ${message}` : message,
|
||||
{ mode: "tools" },
|
||||
);
|
||||
// settings.provider is already resolved from "auto"; never trust an unknown
|
||||
// error object's provider-shaped field for public diagnostics.
|
||||
const provider = options?.provider ?? this.provider?.id ?? this.settings.provider;
|
||||
const debug: MemoryEmbeddingBootstrapDebug = {
|
||||
ok: false,
|
||||
provider,
|
||||
reason,
|
||||
degradedTo: "keyword-only",
|
||||
};
|
||||
if (!options?.retainProvider) {
|
||||
this.provider = null;
|
||||
this.providerRuntime = undefined;
|
||||
}
|
||||
this.providerInitialized = true;
|
||||
this.providerUnavailableReason = reason;
|
||||
this.providerLifecycle = createDegradedMemoryProviderLifecycle({
|
||||
providerId: provider,
|
||||
reason,
|
||||
});
|
||||
this.embeddingBootstrapFailure = debug;
|
||||
this.providerKey = this.computeProviderKey();
|
||||
this.batch = this.resolveBatchConfig();
|
||||
this.vector.semanticAvailable = false;
|
||||
this.cacheProbeResult({ ok: false, error: reason });
|
||||
return debug;
|
||||
}
|
||||
|
||||
protected async ensureEmbeddingProviderForSearch(
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void,
|
||||
): Promise<boolean> {
|
||||
const failure = this.embeddingBootstrapFailure;
|
||||
if (failure) {
|
||||
const cached = this.getCachedEmbeddingAvailability();
|
||||
if (cached?.ok === false) {
|
||||
onDebug?.({ backend: "builtin", embeddingBootstrap: failure });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.ensureProviderInitialized();
|
||||
} catch (err) {
|
||||
if (this.providerRequirement.mode !== "optional") {
|
||||
throw err;
|
||||
}
|
||||
const nextFailure = this.markEmbeddingBootstrapFailure(err);
|
||||
onDebug?.({ backend: "builtin", embeddingBootstrap: nextFailure });
|
||||
return true;
|
||||
}
|
||||
if (!failure) {
|
||||
return false;
|
||||
}
|
||||
if (!this.provider) {
|
||||
const nextFailure: MemoryEmbeddingBootstrapDebug = {
|
||||
...failure,
|
||||
reason: this.providerUnavailableReason ?? failure.reason,
|
||||
};
|
||||
this.embeddingBootstrapFailure = nextFailure;
|
||||
this.cacheProbeResult({ ok: false, error: nextFailure.reason });
|
||||
onDebug?.({ backend: "builtin", embeddingBootstrap: nextFailure });
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentIdentity = this.refreshIndexIdentityDirty({ providerKeyKnown: true });
|
||||
let activeFailure = failure;
|
||||
if (currentIdentity.status !== "valid") {
|
||||
try {
|
||||
await this.syncAdmitted({ reason: "search", force: true });
|
||||
} catch (err) {
|
||||
const message = redactSensitiveText(formatErrorMessage(err), { mode: "tools" });
|
||||
log.warn(`memory sync failed (embedding-bootstrap-recovery): ${message}`);
|
||||
activeFailure = this.markEmbeddingBootstrapFailure(err, { retainProvider: true });
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.refreshIndexIdentityDirty({ providerKeyKnown: true }).status === "valid" &&
|
||||
(await this.confirmEmbeddingBootstrapRecovery())
|
||||
) {
|
||||
// A valid existing index skips recovery reindex, so explicitly restore the
|
||||
// semantic readiness flag cleared when bootstrap degradation began.
|
||||
this.vector.semanticAvailable = await this.probeVectorStoreAvailabilityAdmitted();
|
||||
this.clearEmbeddingBootstrapFailureAfterRecovery();
|
||||
return false;
|
||||
}
|
||||
activeFailure = this.embeddingBootstrapFailure ?? activeFailure;
|
||||
onDebug?.({ backend: "builtin", embeddingBootstrap: activeFailure });
|
||||
return true;
|
||||
}
|
||||
|
||||
protected clearEmbeddingBootstrapFailureAfterRecovery(): void {
|
||||
this.embeddingBootstrapFailure = undefined;
|
||||
this.providerUnavailableReason = undefined;
|
||||
if (this.provider) {
|
||||
this.providerLifecycle = this.fallbackFrom
|
||||
? {
|
||||
mode: "fallback-active",
|
||||
providerId: this.provider.id,
|
||||
fallbackFrom: this.fallbackFrom,
|
||||
reason: this.fallbackReason ?? "fallback activated",
|
||||
}
|
||||
: { mode: "active", providerId: this.provider.id };
|
||||
}
|
||||
EMBEDDING_PROBE_CACHE.delete(this.cacheKey);
|
||||
}
|
||||
|
||||
protected async confirmEmbeddingBootstrapRecovery(): Promise<boolean> {
|
||||
const cached = this.getCachedEmbeddingAvailability();
|
||||
if (cached) {
|
||||
return cached.ok;
|
||||
}
|
||||
if (!this.provider) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.embedBatchWithRetry(["ping"]);
|
||||
this.cacheProbeResult({ ok: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.markEmbeddingBootstrapFailure(err, {
|
||||
retainProvider: true,
|
||||
provider: this.provider.id,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async ensureProviderInitialized(): Promise<void> {
|
||||
if (this.providerInitialized) {
|
||||
const bootstrapRetryDue =
|
||||
this.embeddingBootstrapFailure !== undefined &&
|
||||
!this.provider &&
|
||||
this.getCachedEmbeddingAvailability() === null;
|
||||
if (!bootstrapRetryDue) {
|
||||
await this.getPendingFallbackProviderInitialization()?.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
this.resetProviderInitializationForRetry();
|
||||
}
|
||||
if (this.settings.provider === "none") {
|
||||
this.applyProviderResult({
|
||||
provider: null,
|
||||
requestedProvider: "none",
|
||||
providerUnavailableReason: "No embedding provider available (FTS-only mode)",
|
||||
});
|
||||
this.providerKey = this.computeProviderKey();
|
||||
this.batch = this.resolveBatchConfig();
|
||||
return;
|
||||
}
|
||||
if (!this.providerInitPromise) {
|
||||
this.providerInitPromise = (async () => {
|
||||
await this.getPendingFallbackProviderInitialization()?.catch(() => undefined);
|
||||
await this.retireCurrentProvider();
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const providerResult = await createEmbeddingProvider({
|
||||
config: this.cfg,
|
||||
agentDir: resolveAgentDir(this.cfg, this.agentId),
|
||||
...(this.acquireLocalService ? { acquireLocalService: this.acquireLocalService } : {}),
|
||||
...resolveMemoryPrimaryProviderRequest({ settings: this.settings }),
|
||||
});
|
||||
this.applyProviderResult(providerResult);
|
||||
this.providerKey = this.computeProviderKey();
|
||||
this.batch = this.resolveBatchConfig();
|
||||
})();
|
||||
}
|
||||
try {
|
||||
await this.providerInitPromise;
|
||||
} catch (err) {
|
||||
// Clear the cached rejected promise so subsequent calls can retry
|
||||
// initialization instead of being permanently stuck with a stale failure.
|
||||
this.providerInitPromise = null;
|
||||
throw err;
|
||||
} finally {
|
||||
if (this.providerInitialized) {
|
||||
this.providerInitPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected resetProviderInitializationForRetry(): void {
|
||||
void this.retireCurrentProvider();
|
||||
this.providerInitialized = false;
|
||||
this.providerInitPromise = null;
|
||||
this.providerUnavailableReason = undefined;
|
||||
this.providerLifecycle = createPendingMemoryProviderLifecycle(this.requestedProvider);
|
||||
}
|
||||
|
||||
protected markLocalEmbeddingProviderDegraded(err: unknown): void {
|
||||
if (this.provider?.id !== "local") {
|
||||
return;
|
||||
}
|
||||
const workerFailure = isLocalEmbeddingWorkerFailure(err)
|
||||
? err
|
||||
: err instanceof Error && isLocalEmbeddingWorkerFailure(err.cause)
|
||||
? err.cause
|
||||
: null;
|
||||
if (!workerFailure) {
|
||||
return;
|
||||
}
|
||||
const message = formatErrorMessage(workerFailure);
|
||||
const degradedProvider = this.provider;
|
||||
void this.retireCurrentProvider();
|
||||
this.providerUnavailableReason = `Local embeddings degraded: ${message}`;
|
||||
this.providerLifecycle = createDegradedMemoryProviderLifecycle({
|
||||
providerId: degradedProvider.id,
|
||||
reason: message,
|
||||
code: workerFailure.code,
|
||||
});
|
||||
EMBEDDING_PROBE_CACHE.delete(this.cacheKey);
|
||||
this.providerKey = this.computeProviderKey();
|
||||
this.batch = this.resolveBatchConfig();
|
||||
this.vector.semanticAvailable = false;
|
||||
log.warn("memory embeddings: local provider degraded after worker failure", {
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
protected override retireCurrentProvider(): Promise<void> {
|
||||
const provider = this.provider;
|
||||
if (provider) {
|
||||
this.provider = null;
|
||||
this.providerRuntime = undefined;
|
||||
this.providersPendingRetirement.add(provider);
|
||||
}
|
||||
if (this.providersPendingRetirement.size === 0) {
|
||||
return this.providerRetirementPromise;
|
||||
}
|
||||
// Provider replacement must wait for the previous worker to exit; otherwise
|
||||
// repeated retries can accumulate local workers on constrained hosts.
|
||||
const retirement = this.providerRetirementPromise
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
let firstError: unknown;
|
||||
let closeFailed = false;
|
||||
for (const pendingProvider of this.providersPendingRetirement) {
|
||||
try {
|
||||
await this.awaitProviderIdle(pendingProvider);
|
||||
await pendingProvider.close?.();
|
||||
this.providersPendingRetirement.delete(pendingProvider);
|
||||
} catch (err) {
|
||||
if (!closeFailed) {
|
||||
firstError = err;
|
||||
}
|
||||
closeFailed = true;
|
||||
}
|
||||
}
|
||||
if (closeFailed) {
|
||||
throw toErrorObject(firstError, "Embedding provider retirement failed");
|
||||
}
|
||||
});
|
||||
this.providerRetirementPromise = retirement;
|
||||
void retirement.catch((err: unknown) => {
|
||||
log.warn(`memory embeddings: failed to close previous provider: ${formatErrorMessage(err)}`);
|
||||
});
|
||||
return retirement;
|
||||
}
|
||||
|
||||
protected async drainPendingProviderRetirements(): Promise<unknown[]> {
|
||||
const errors: unknown[] = [];
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 2 && (this.provider !== null || this.providersPendingRetirement.size > 0);
|
||||
attempt += 1
|
||||
) {
|
||||
try {
|
||||
await this.retireCurrentProvider();
|
||||
} catch (err) {
|
||||
errors.push(err);
|
||||
log.warn(`memory close: pending manager work failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
protected isRequiredProviderUnavailable(): boolean {
|
||||
return this.providerRequirement.mode === "required" && !this.provider;
|
||||
}
|
||||
|
||||
protected buildRequiredProviderUnavailableError(operation: "search" | "sync"): Error {
|
||||
const registeredProviderIds = listRegisteredMemoryEmbeddingProviderAdapters()
|
||||
.map((adapter) => adapter.id)
|
||||
.toSorted();
|
||||
const registeredProviders =
|
||||
registeredProviderIds.length > 0 ? registeredProviderIds.join(",") : "none";
|
||||
const reason =
|
||||
this.providerUnavailableReason ??
|
||||
(this.providerLifecycle.mode === "fts-only"
|
||||
? this.providerLifecycle.reason
|
||||
: "provider is unavailable");
|
||||
return new Error(
|
||||
`Memory ${operation} unavailable: embedding provider "${this.settings.provider}" is configured but unavailable. ` +
|
||||
`Reason: ${reason}. ` +
|
||||
`agentId=${this.agentId} purpose=${this.purpose} lifecycle=${JSON.stringify(this.providerLifecycle)} ` +
|
||||
`registeredMemoryEmbeddingProviders=${registeredProviders}`,
|
||||
);
|
||||
}
|
||||
|
||||
protected assertRequiredProviderAvailable(operation: "search" | "sync"): void {
|
||||
if (this.isRequiredProviderUnavailable()) {
|
||||
const error = this.buildRequiredProviderUnavailableError(operation);
|
||||
this.resetProviderInitializationForRetry();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected refreshIndexIdentityDirty(params?: { providerKeyKnown?: boolean }) {
|
||||
const provider =
|
||||
this.settings.provider === "none"
|
||||
? null
|
||||
: this.providerInitialized
|
||||
? this.provider
|
||||
? { id: this.provider.id, model: this.provider.model }
|
||||
: null
|
||||
: undefined;
|
||||
const state = this.resolveCurrentIndexIdentityState({
|
||||
...(provider !== undefined ? { provider } : {}),
|
||||
providerKeyKnown: params?.providerKeyKnown,
|
||||
});
|
||||
this.indexIdentityState = state;
|
||||
this.indexIdentityDirty =
|
||||
state.status === "mismatched" ||
|
||||
(state.status === "missing" && (this.sources.has("memory") || this.hasIndexedChunks()));
|
||||
return state;
|
||||
}
|
||||
|
||||
protected refreshKeywordFallbackIndexIdentity() {
|
||||
const meta = this.readMeta();
|
||||
const state = this.resolveCurrentIndexIdentityState({
|
||||
meta,
|
||||
provider: meta && meta.provider !== "none" ? { id: meta.provider, model: meta.model } : null,
|
||||
providerKeyKnown: false,
|
||||
vectorReady: false,
|
||||
});
|
||||
this.indexIdentityState = state;
|
||||
this.indexIdentityDirty =
|
||||
state.status === "mismatched" ||
|
||||
(state.status === "missing" && (this.sources.has("memory") || this.hasIndexedChunks()));
|
||||
return state;
|
||||
}
|
||||
|
||||
protected async withManagerOperation<T>(run: () => Promise<T>): Promise<T> {
|
||||
if (this.closing || this.closed) {
|
||||
throw new Error("Memory index manager is closed");
|
||||
}
|
||||
this.activeManagerOperations += 1;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
this.activeManagerOperations -= 1;
|
||||
if (this.activeManagerOperations === 0) {
|
||||
const waiters = Array.from(this.managerIdleWaiters);
|
||||
this.managerIdleWaiters.clear();
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async awaitManagerIdle(): Promise<void> {
|
||||
if (this.activeManagerOperations === 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
this.managerIdleWaiters.add(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async probeVectorAvailability(): Promise<boolean> {
|
||||
return await this.withManagerOperation(async () => {
|
||||
if (!this.vector.enabled) {
|
||||
this.vector.semanticAvailable = false;
|
||||
return false;
|
||||
}
|
||||
await this.ensureProviderInitialized();
|
||||
// FTS-only mode: vector search not available
|
||||
if (!this.provider) {
|
||||
this.vector.semanticAvailable = false;
|
||||
return false;
|
||||
}
|
||||
const ready = await this.probeVectorStoreAvailabilityAdmitted();
|
||||
this.vector.semanticAvailable = ready;
|
||||
return ready;
|
||||
});
|
||||
}
|
||||
|
||||
async probeVectorStoreAvailability(): Promise<boolean> {
|
||||
return await this.withManagerOperation(
|
||||
async () => await this.probeVectorStoreAvailabilityAdmitted(),
|
||||
);
|
||||
}
|
||||
|
||||
private async probeVectorStoreAvailabilityAdmitted(): Promise<boolean> {
|
||||
if (!this.vector.enabled) {
|
||||
this.vector.available = false;
|
||||
return false;
|
||||
}
|
||||
return await this.ensureVectorReady();
|
||||
}
|
||||
|
||||
protected cacheProbeResult(result: MemoryEmbeddingProbeResult): MemoryEmbeddingProbeResult {
|
||||
const checkedAtMs = Date.now();
|
||||
EMBEDDING_PROBE_CACHE.set(this.cacheKey, {
|
||||
result,
|
||||
checkedAtMs,
|
||||
expireAtMs: checkedAtMs + EMBEDDING_PROBE_CACHE_TTL_MS,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
getCachedEmbeddingAvailability(): MemoryEmbeddingProbeResult | null {
|
||||
const cached = EMBEDDING_PROBE_CACHE.get(this.cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (nowMs >= cached.expireAtMs) {
|
||||
EMBEDDING_PROBE_CACHE.delete(this.cacheKey);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...cached.result,
|
||||
checked: true,
|
||||
cached: true,
|
||||
checkedAtMs: cached.checkedAtMs,
|
||||
cacheExpiresAtMs: cached.expireAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
|
||||
return await this.withManagerOperation(async () => {
|
||||
const cached = this.getCachedEmbeddingAvailability();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
await this.ensureProviderInitialized();
|
||||
// FTS-only mode: embeddings not available but search still works
|
||||
if (!this.provider) {
|
||||
return this.cacheProbeResult({
|
||||
ok: false,
|
||||
error:
|
||||
this.providerUnavailableReason ?? "No embedding provider available (FTS-only mode)",
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.embedBatchWithRetry(["ping"]);
|
||||
return this.cacheProbeResult({ ok: true });
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
return this.cacheProbeResult({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -172,10 +172,10 @@ describe("memory manager mistral provider wiring", () => {
|
||||
};
|
||||
const remote = {
|
||||
baseUrl: "https://primary-openai.invalid/v1",
|
||||
apiKey: "synthetic-primary-openai-api-key",
|
||||
apiKey: "test-key",
|
||||
headers: {
|
||||
Authorization: "Bearer synthetic-primary-openai-auth",
|
||||
"X-OpenAI-Secret": "synthetic-primary-openai-header",
|
||||
Authorization: "Bearer test-secret",
|
||||
"X-OpenAI-Secret": "test-token",
|
||||
},
|
||||
...sharedRemote,
|
||||
};
|
||||
@@ -0,0 +1,809 @@
|
||||
// Memory Core tests cover manager registry behavior.
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import {
|
||||
closeAllMemoryIndexManagers,
|
||||
closeMemoryIndexManagersForAgent,
|
||||
MemoryIndexManager as RuntimeMemoryIndexManager,
|
||||
} from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
vi.setConfig({ testTimeout: 240_000 });
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetConfig();
|
||||
});
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embeddedBatchTexts: string[] = [];
|
||||
let embedBatchInputCalls = 0;
|
||||
let providerRuntimeBatchCalls: string[][] = [];
|
||||
let providerRuntimeBatchGate: Promise<void> | null = null;
|
||||
let providerRuntimeBatchErrors: unknown[] = [];
|
||||
let providerRuntimeBatchFailuresRemaining = 0;
|
||||
let providerRuntimeActiveBatchCalls = 0;
|
||||
let providerRuntimeMaxActiveBatchCalls = 0;
|
||||
let providerCloseCalls = 0;
|
||||
let providerCloseFailuresRemaining = 0;
|
||||
let providerCloseFailure: unknown = new Error("provider close failed");
|
||||
let providerCreationFailure: string | null = null;
|
||||
let providerNullResult: string | null = null;
|
||||
let providerCloseGate: Promise<void> | null = null;
|
||||
let providerInitGate: Promise<void> | null = null;
|
||||
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
|
||||
let forceNoProvider = false;
|
||||
|
||||
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
const identityAliasFixture = vi.hoisted(() => ({
|
||||
provider: "identity-alias-test",
|
||||
canonicalModel: "hf:fixture/default-model.gguf",
|
||||
cacheModel: "/fixture/cache/default-model.gguf",
|
||||
}));
|
||||
|
||||
function setMemoryIndexStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
|
||||
function restoreMemoryIndexStateDir(): void {
|
||||
if (originalMemoryIndexStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embeddings.js")>();
|
||||
const embedText = (text: string) => {
|
||||
const lower = text.toLowerCase();
|
||||
const alpha = lower.split("alpha").length - 1;
|
||||
const beta = lower.split("beta").length - 1;
|
||||
const image = lower.split("image").length - 1;
|
||||
const audio = lower.split("audio").length - 1;
|
||||
return [alpha, beta, image, audio];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => 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;
|
||||
outputDimensionality?: number;
|
||||
}) => {
|
||||
providerCalls.push({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
});
|
||||
await providerInitGate;
|
||||
if (options.provider === providerCreationFailure) {
|
||||
throw new Error(`provider creation failed: ${options.provider}`);
|
||||
}
|
||||
if (options.provider === providerNullResult) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider,
|
||||
providerUnavailableReason: `provider unavailable: ${options.provider}`,
|
||||
};
|
||||
}
|
||||
if (forceNoProvider) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider ?? "auto",
|
||||
providerUnavailableReason: "No API key found for provider",
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "batch-test" ||
|
||||
options.provider === "batch-wide-test" ||
|
||||
options.provider === identityAliasFixture.provider ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
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: {
|
||||
id: providerId,
|
||||
model,
|
||||
close: async () => {
|
||||
providerCloseCalls += 1;
|
||||
await providerCloseGate;
|
||||
if (providerCloseFailuresRemaining > 0) {
|
||||
providerCloseFailuresRemaining -= 1;
|
||||
throw providerCloseFailure;
|
||||
}
|
||||
},
|
||||
embedQuery: async (text: string) => embedText(text),
|
||||
embedBatch: async (texts: string[]) => {
|
||||
embedBatchCalls += 1;
|
||||
embeddedBatchTexts.push(...texts);
|
||||
return texts.map(embedText);
|
||||
},
|
||||
...(providerId === "gemini" || providerId === "fallback-provider"
|
||||
? {
|
||||
embedBatchInputs: async (
|
||||
inputs: Array<{
|
||||
text: string;
|
||||
parts?: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "inline-data"; mimeType: string; data: string }
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
embedBatchInputCalls += 1;
|
||||
return inputs.map((input) => {
|
||||
const inlineData = input.parts?.find((part) => part.type === "inline-data");
|
||||
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
|
||||
throw new Error("payload too large");
|
||||
}
|
||||
const mimeType =
|
||||
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return [0, 0, 1, 0];
|
||||
}
|
||||
if (mimeType?.startsWith("audio/")) {
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
return embedText(input.text);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(providerId === identityAliasFixture.provider
|
||||
? {
|
||||
runtime: {
|
||||
id: providerId,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.canonicalModel,
|
||||
},
|
||||
indexIdentityAliases: [
|
||||
{
|
||||
model: identityAliasFixture.cacheModel,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.cacheModel,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: providerId === "batch-test" || providerId === "batch-wide-test"
|
||||
? {
|
||||
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 (providerRuntimeBatchErrors.length > 0) {
|
||||
throw providerRuntimeBatchErrors.shift();
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
const managersForCleanup = new Set<MemoryIndexManager>();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
embedBatchCalls = 0;
|
||||
embeddedBatchTexts = [];
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
providerRuntimeBatchGate = null;
|
||||
providerRuntimeBatchErrors = [];
|
||||
providerRuntimeBatchFailuresRemaining = 0;
|
||||
providerRuntimeActiveBatchCalls = 0;
|
||||
providerRuntimeMaxActiveBatchCalls = 0;
|
||||
providerCloseCalls = 0;
|
||||
providerCloseFailuresRemaining = 0;
|
||||
providerCloseFailure = new Error("provider close failed");
|
||||
providerCreationFailure = null;
|
||||
providerNullResult = null;
|
||||
providerCloseGate = null;
|
||||
providerInitGate = null;
|
||||
providerCalls = [];
|
||||
forceNoProvider = false;
|
||||
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
});
|
||||
|
||||
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
|
||||
|
||||
function createCfg(params: {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
rememberAcrossConversations?: boolean;
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
batchEnabled?: boolean;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
enabled?: boolean;
|
||||
modalities?: Array<"image" | "audio" | "all">;
|
||||
maxFileBytes?: number;
|
||||
};
|
||||
vectorEnabled?: boolean;
|
||||
cacheEnabled?: boolean;
|
||||
minScore?: number;
|
||||
onSearch?: boolean;
|
||||
hybrid?: {
|
||||
enabled: boolean;
|
||||
vectorWeight?: number;
|
||||
textWeight?: number;
|
||||
temporalDecay?: { enabled: boolean };
|
||||
};
|
||||
}): TestCfg {
|
||||
return isolateMemoryManagerTestConfig({
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: {
|
||||
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
|
||||
},
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
batch: { enabled: true },
|
||||
}
|
||||
: undefined,
|
||||
query: { minScore: params.minScore ?? 0 },
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
rememberAcrossConversations:
|
||||
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function requireManager(
|
||||
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||
missingMessage = "manager missing",
|
||||
): MemoryIndexManager {
|
||||
if (!result.manager) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return result.manager as unknown as MemoryIndexManager;
|
||||
}
|
||||
|
||||
async function getFreshManager(
|
||||
cfg: TestCfg,
|
||||
purpose?: "default" | "status" | "cli",
|
||||
): Promise<MemoryIndexManager> {
|
||||
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
|
||||
managersForCleanup.add(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
it("waits for scoped manager close before initializing a replacement", async () => {
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
const closePromise = closeMemoryIndexManagersForAgent({ agentId: "main" });
|
||||
const callsBeforeReplacement = providerCalls.length;
|
||||
const secondPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
|
||||
requireManager(result),
|
||||
);
|
||||
const concurrentSecondPromise = getMemorySearchManager({ cfg, agentId: "main" }).then(
|
||||
(result) => requireManager(result),
|
||||
);
|
||||
const secondProbe = secondPromise.then(async (manager) => {
|
||||
await manager.probeEmbeddingAvailability();
|
||||
});
|
||||
let secondSettled = false;
|
||||
void secondPromise.then(
|
||||
() => {
|
||||
secondSettled = true;
|
||||
},
|
||||
() => {
|
||||
secondSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(providerCloseCalls).toBe(1);
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(secondSettled).toBe(false);
|
||||
expect(providerCalls).toHaveLength(callsBeforeReplacement);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
await closePromise;
|
||||
const second = await secondPromise;
|
||||
const concurrentSecond = await concurrentSecondPromise;
|
||||
await secondProbe;
|
||||
managersForCleanup.add(second);
|
||||
expect(second === first).toBe(false);
|
||||
expect(concurrentSecond).toBe(second);
|
||||
|
||||
const third = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
|
||||
managersForCleanup.add(third);
|
||||
expect(third).toBe(second);
|
||||
});
|
||||
|
||||
it("does not reuse a cached manager after direct close starts", async () => {
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
|
||||
const closePromise = first.close();
|
||||
const replacementPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
|
||||
requireManager(result),
|
||||
);
|
||||
let replacementSettled = false;
|
||||
void replacementPromise.then(
|
||||
() => {
|
||||
replacementSettled = true;
|
||||
},
|
||||
() => {
|
||||
replacementSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
await Promise.resolve();
|
||||
expect(replacementSettled).toBe(false);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
|
||||
await closePromise;
|
||||
const replacement = await replacementPromise;
|
||||
managersForCleanup.add(replacement);
|
||||
expect(replacement === first).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes concurrent acquisitions with different cache identities", async () => {
|
||||
const firstCfg = createCfg({
|
||||
model: "first-model",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const first = requireManager(await getMemorySearchManager({ cfg: firstCfg, agentId: "main" }));
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
|
||||
const secondPromise = getMemorySearchManager({
|
||||
cfg: createCfg({ model: "second-model" }),
|
||||
agentId: "main",
|
||||
}).then((result) => requireManager(result));
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
const thirdPromise = getMemorySearchManager({
|
||||
cfg: createCfg({ model: "third-model" }),
|
||||
agentId: "main",
|
||||
}).then((result) => requireManager(result));
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(providerCalls).toHaveLength(1);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
|
||||
const [second, third] = await Promise.all([secondPromise, thirdPromise]);
|
||||
managersForCleanup.add(second);
|
||||
managersForCleanup.add(third);
|
||||
expect(second === first).toBe(false);
|
||||
expect(third === second).toBe(false);
|
||||
expect((second as unknown as { closed: boolean }).closed).toBe(true);
|
||||
expect((third as unknown as { closed: boolean }).closed).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes agent ids before builtin manager acquisition", async () => {
|
||||
const cfg = createCfg({ model: "canonical-model" });
|
||||
const first = await RuntimeMemoryIndexManager.get({ cfg, agentId: "Main-Agent" });
|
||||
const second = await RuntimeMemoryIndexManager.get({ cfg, agentId: "main-agent" });
|
||||
if (!first || !second) {
|
||||
throw new Error("Expected canonical memory index managers");
|
||||
}
|
||||
managersForCleanup.add(first);
|
||||
managersForCleanup.add(second);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("retires the prior builtin manager when an agent workspace changes", async () => {
|
||||
const firstCfg = createCfg({ model: "workspace-model" });
|
||||
const secondCfg = createCfg({ model: "workspace-model" });
|
||||
if (!firstCfg.agents?.defaults || !secondCfg.agents?.defaults) {
|
||||
throw new Error("Expected agent defaults");
|
||||
}
|
||||
firstCfg.agents.defaults.workspace = path.join(fixtureRoot, "workspace-a");
|
||||
secondCfg.agents.defaults.workspace = path.join(fixtureRoot, "workspace-b");
|
||||
|
||||
const first = await RuntimeMemoryIndexManager.get({ cfg: firstCfg, agentId: "main" });
|
||||
const second = await RuntimeMemoryIndexManager.get({ cfg: secondCfg, agentId: "main" });
|
||||
if (!first || !second) {
|
||||
throw new Error("Expected workspace memory index managers");
|
||||
}
|
||||
managersForCleanup.add(first);
|
||||
managersForCleanup.add(second);
|
||||
expect(second === first).toBe(false);
|
||||
expect((first as unknown as { closed: boolean }).closed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not block another agent while one scope retires its manager", async () => {
|
||||
const firstCfg = createCfg({
|
||||
model: "first-model",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const first = requireManager(await getMemorySearchManager({ cfg: firstCfg, agentId: "main" }));
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
|
||||
const replacementPromise = getMemorySearchManager({
|
||||
cfg: createCfg({ model: "second-model" }),
|
||||
agentId: "main",
|
||||
});
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
const otherAgentPromise = getMemorySearchManager({
|
||||
cfg: createCfg({ model: "other-model" }),
|
||||
agentId: "other",
|
||||
});
|
||||
let otherAgentSettled = false;
|
||||
void otherAgentPromise.then(
|
||||
() => {
|
||||
otherAgentSettled = true;
|
||||
},
|
||||
() => {
|
||||
otherAgentSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => expect(otherAgentSettled).toBe(true));
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
|
||||
const otherAgent = requireManager(await otherAgentPromise);
|
||||
const replacement = requireManager(await replacementPromise);
|
||||
managersForCleanup.add(otherAgent);
|
||||
managersForCleanup.add(replacement);
|
||||
expect((otherAgent as unknown as { closed: boolean }).closed).toBe(false);
|
||||
});
|
||||
|
||||
it("global teardown waits for an admitted builtin manager replacement", async () => {
|
||||
const first = await RuntimeMemoryIndexManager.get({
|
||||
cfg: createCfg({ model: "first-model" }),
|
||||
agentId: "main",
|
||||
});
|
||||
if (!first) {
|
||||
throw new Error("Expected first memory index manager");
|
||||
}
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
|
||||
const replacementPromise = RuntimeMemoryIndexManager.get({
|
||||
cfg: createCfg({ model: "second-model" }),
|
||||
agentId: "main",
|
||||
});
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(1));
|
||||
const globalClosePromise = closeAllMemoryIndexManagers();
|
||||
let globalCloseSettled = false;
|
||||
void globalClosePromise.then(
|
||||
() => {
|
||||
globalCloseSettled = true;
|
||||
},
|
||||
() => {
|
||||
globalCloseSettled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(globalCloseSettled).toBe(false);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
|
||||
const replacement = await replacementPromise;
|
||||
await globalClosePromise;
|
||||
if (!replacement) {
|
||||
throw new Error("Expected replacement memory index manager");
|
||||
}
|
||||
managersForCleanup.add(replacement);
|
||||
expect((replacement as unknown as { closed: boolean }).closed).toBe(true);
|
||||
});
|
||||
|
||||
it("retains a failed scoped close owner until provider retirement succeeds", async () => {
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
providerCloseFailuresRemaining = 2;
|
||||
|
||||
await expect(closeMemoryIndexManagersForAgent({ agentId: "main" })).rejects.toThrow(
|
||||
"provider close failed",
|
||||
);
|
||||
expect(providerCloseCalls).toBe(2);
|
||||
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
const callsBeforeReplacement = providerCalls.length;
|
||||
const replacementPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
|
||||
requireManager(result),
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(3));
|
||||
expect(providerCalls).toHaveLength(callsBeforeReplacement);
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
|
||||
const replacement = await replacementPromise;
|
||||
managersForCleanup.add(replacement);
|
||||
expect(replacement === first).toBe(false);
|
||||
});
|
||||
|
||||
it("retains a failed global close owner until provider retirement succeeds", async () => {
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
|
||||
managersForCleanup.add(first);
|
||||
await first.probeEmbeddingAvailability();
|
||||
providerCloseFailuresRemaining = 2;
|
||||
providerCloseFailure = undefined;
|
||||
|
||||
let globalCloseRejected = false;
|
||||
await closeAllMemorySearchManagers().then(
|
||||
() => {},
|
||||
() => {
|
||||
globalCloseRejected = true;
|
||||
},
|
||||
);
|
||||
expect(globalCloseRejected).toBe(true);
|
||||
expect(providerCloseCalls).toBe(2);
|
||||
|
||||
let releaseProviderClose: () => void = () => {};
|
||||
providerCloseGate = new Promise<void>((resolve) => {
|
||||
releaseProviderClose = resolve;
|
||||
});
|
||||
const callsBeforeReplacement = providerCalls.length;
|
||||
const replacementPromise = getMemorySearchManager({ cfg, agentId: "main" }).then((result) =>
|
||||
requireManager(result),
|
||||
);
|
||||
let concurrentGlobalClose: Promise<void> = Promise.resolve();
|
||||
try {
|
||||
await vi.waitFor(() => expect(providerCloseCalls).toBe(3));
|
||||
expect(providerCalls).toHaveLength(callsBeforeReplacement);
|
||||
concurrentGlobalClose = closeAllMemorySearchManagers();
|
||||
} finally {
|
||||
releaseProviderClose();
|
||||
providerCloseGate = null;
|
||||
}
|
||||
|
||||
const replacement = await replacementPromise;
|
||||
await concurrentGlobalClose;
|
||||
managersForCleanup.add(replacement);
|
||||
expect(replacement === first).toBe(false);
|
||||
expect((replacement as unknown as { closed: boolean }).closed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse memory index managers across local-service hosts", async () => {
|
||||
const cfg = createCfg({});
|
||||
const firstAcquire = vi.fn(async () => undefined);
|
||||
const secondAcquire = vi.fn(async () => undefined);
|
||||
const first = requireManager(
|
||||
await getMemorySearchManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
acquireLocalService: firstAcquire,
|
||||
}),
|
||||
);
|
||||
managersForCleanup.add(first);
|
||||
|
||||
const second = requireManager(
|
||||
await getMemorySearchManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
acquireLocalService: secondAcquire,
|
||||
}),
|
||||
);
|
||||
managersForCleanup.add(second);
|
||||
const secondAgain = requireManager(
|
||||
await getMemorySearchManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
acquireLocalService: secondAcquire,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Object.is(second, first)).toBe(false);
|
||||
expect(Object.is(secondAgain, second)).toBe(true);
|
||||
});
|
||||
|
||||
it("retries embedding provider close before releasing the manager", async () => {
|
||||
providerCloseFailuresRemaining = 1;
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
|
||||
await manager.probeEmbeddingAvailability();
|
||||
await manager.close();
|
||||
|
||||
expect(providerCloseCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
// Memory Core plugin module owns manager cache and close serialization.
|
||||
import { toErrorObject } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
resolveGlobalSingleton,
|
||||
type ResolvedMemorySearchConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
resolveMemoryCoreLocalServiceHostIdentity,
|
||||
type MemoryCoreAcquireLocalService,
|
||||
} from "./embedding-local-service.js";
|
||||
import { getOrCreateManagedCacheEntry, resolveSingletonManagedCache } from "./manager-cache.js";
|
||||
|
||||
const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache");
|
||||
const MEMORY_INDEX_MANAGER_SCOPE_CLOSES_KEY = Symbol.for("openclaw.memoryIndexManagerScopeCloses");
|
||||
const MEMORY_INDEX_MANAGER_GLOBAL_LIFECYCLE_KEY = Symbol.for(
|
||||
"openclaw.memoryIndexManagerGlobalLifecycle.v3",
|
||||
);
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
export type MemoryIndexManagerPurpose = "default" | "status" | "cli";
|
||||
|
||||
type ClosableMemoryManager = {
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type PreparedMemoryManager<T extends ClosableMemoryManager> = {
|
||||
key: string;
|
||||
transient: boolean;
|
||||
create: () => Promise<T> | T;
|
||||
reuse: (manager: T) => boolean;
|
||||
};
|
||||
|
||||
type MemoryManagerRegistryCallbacks<T extends ClosableMemoryManager> = {
|
||||
prepare: () => Promise<PreparedMemoryManager<T> | null> | PreparedMemoryManager<T> | null;
|
||||
close: (manager: T) => Promise<void>;
|
||||
};
|
||||
|
||||
type MemoryManagerRegistryGlobalLifecycle = {
|
||||
closePromise: Promise<void> | null;
|
||||
closeFailed: boolean;
|
||||
};
|
||||
|
||||
export function resolveMemoryIndexManagerCacheKey(params: {
|
||||
agentId: string;
|
||||
workspaceDir: string;
|
||||
settings: ResolvedMemorySearchConfig;
|
||||
providerRequirement: unknown;
|
||||
purpose: MemoryIndexManagerPurpose;
|
||||
acquireLocalService?: MemoryCoreAcquireLocalService;
|
||||
}): string {
|
||||
return [
|
||||
params.agentId,
|
||||
params.workspaceDir,
|
||||
JSON.stringify(params.settings),
|
||||
JSON.stringify(params.providerRequirement),
|
||||
resolveMemoryCoreLocalServiceHostIdentity(params.acquireLocalService),
|
||||
params.purpose,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export class MemoryManagerRegistry<T extends ClosableMemoryManager> {
|
||||
private readonly cache: Map<string, T>;
|
||||
private readonly pending: Map<string, Promise<T>>;
|
||||
private readonly scopeOperations: Map<string, Promise<void>>;
|
||||
private readonly globalLifecycle: MemoryManagerRegistryGlobalLifecycle;
|
||||
|
||||
constructor() {
|
||||
const managedCache = resolveSingletonManagedCache<T>(MEMORY_INDEX_MANAGER_CACHE_KEY);
|
||||
this.cache = managedCache.cache;
|
||||
this.pending = managedCache.pending;
|
||||
this.scopeOperations = resolveGlobalSingleton<Map<string, Promise<void>>>(
|
||||
MEMORY_INDEX_MANAGER_SCOPE_CLOSES_KEY,
|
||||
() => new Map(),
|
||||
);
|
||||
this.globalLifecycle = resolveGlobalSingleton<MemoryManagerRegistryGlobalLifecycle>(
|
||||
MEMORY_INDEX_MANAGER_GLOBAL_LIFECYCLE_KEY,
|
||||
() => ({ closePromise: null, closeFailed: false }),
|
||||
);
|
||||
}
|
||||
|
||||
async acquire(
|
||||
params: { agentId: string; purpose: MemoryIndexManagerPurpose },
|
||||
callbacks: MemoryManagerRegistryCallbacks<T>,
|
||||
): Promise<T | null> {
|
||||
return await this.runScopeOperation(params, async () => {
|
||||
if (this.globalLifecycle.closeFailed) {
|
||||
await this.retryFailedGlobalClose(callbacks.close);
|
||||
}
|
||||
const prepared = await callbacks.prepare();
|
||||
if (!prepared) {
|
||||
return null;
|
||||
}
|
||||
const getOrCreate = async () =>
|
||||
await getOrCreateManagedCacheEntry({
|
||||
cache: this.cache,
|
||||
pending: this.pending,
|
||||
key: prepared.key,
|
||||
bypassCache: prepared.transient,
|
||||
create: prepared.create,
|
||||
});
|
||||
if (prepared.transient) {
|
||||
return await getOrCreate();
|
||||
}
|
||||
const cachedManager = this.cache.get(prepared.key);
|
||||
await this.closeScopeUnlocked(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
purpose: params.purpose,
|
||||
...(cachedManager && prepared.reuse(cachedManager) ? { exceptKey: prepared.key } : {}),
|
||||
},
|
||||
callbacks.close,
|
||||
);
|
||||
return await getOrCreate();
|
||||
});
|
||||
}
|
||||
|
||||
async closeAll(close: (manager: T) => Promise<void>): Promise<void> {
|
||||
await this.runGlobalClose(async () => {
|
||||
try {
|
||||
await this.closeAllUnlocked(close);
|
||||
this.globalLifecycle.closeFailed = false;
|
||||
} catch (err) {
|
||||
this.globalLifecycle.closeFailed = true;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async closeForAgent(params: {
|
||||
agentId: string;
|
||||
purpose: MemoryIndexManagerPurpose;
|
||||
close: (manager: T) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const scope = { agentId: normalizeAgentId(params.agentId), purpose: params.purpose };
|
||||
await this.runScopeOperation(scope, async () => {
|
||||
await this.closeScopeUnlocked(scope, params.close);
|
||||
});
|
||||
}
|
||||
|
||||
deleteIfCurrent(key: string, manager: T): void {
|
||||
if (this.cache.get(key) === manager) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async retryFailedGlobalClose(close: (manager: T) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await this.closeAllUnlocked(close);
|
||||
this.globalLifecycle.closeFailed = false;
|
||||
} catch (err) {
|
||||
this.globalLifecycle.closeFailed = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async runGlobalClose(operation: () => Promise<void>): Promise<void> {
|
||||
const previous = this.globalLifecycle.closePromise ?? Promise.resolve();
|
||||
const closePromise = previous.then(operation, operation);
|
||||
this.globalLifecycle.closePromise = closePromise;
|
||||
await closePromise;
|
||||
if (this.globalLifecycle.closePromise === closePromise) {
|
||||
this.globalLifecycle.closePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async runScopeOperation<R>(
|
||||
params: { agentId: string; purpose: MemoryIndexManagerPurpose },
|
||||
operation: () => Promise<R>,
|
||||
): Promise<R> {
|
||||
while (this.globalLifecycle.closePromise) {
|
||||
const globalClose = this.globalLifecycle.closePromise;
|
||||
try {
|
||||
await globalClose;
|
||||
} catch {
|
||||
if (this.globalLifecycle.closePromise === globalClose) {
|
||||
await this.closeAll(async (manager) => await manager.close());
|
||||
}
|
||||
}
|
||||
}
|
||||
const scopeKey = JSON.stringify([params.agentId, params.purpose]);
|
||||
const previousOperation = this.scopeOperations.get(scopeKey) ?? Promise.resolve();
|
||||
const result = previousOperation.then(operation, operation);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.scopeOperations.set(scopeKey, tail);
|
||||
try {
|
||||
return await result;
|
||||
} finally {
|
||||
if (this.scopeOperations.get(scopeKey) === tail) {
|
||||
this.scopeOperations.delete(scopeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async closeAllUnlocked(close: (manager: T) => Promise<void>): Promise<void> {
|
||||
const scopedOperations = Array.from(this.scopeOperations.values());
|
||||
if (scopedOperations.length > 0) {
|
||||
await Promise.allSettled(scopedOperations);
|
||||
}
|
||||
const pending = Array.from(this.pending.values());
|
||||
if (pending.length > 0) {
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
await this.closeEntries(Array.from(this.cache.entries()), close);
|
||||
}
|
||||
|
||||
private async closeScopeUnlocked(
|
||||
params: {
|
||||
agentId: string;
|
||||
purpose: MemoryIndexManagerPurpose;
|
||||
exceptKey?: string;
|
||||
},
|
||||
close: (manager: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const isScopedKey = (key: string) =>
|
||||
key !== params.exceptKey &&
|
||||
key.startsWith(`${params.agentId}:`) &&
|
||||
key.endsWith(`:${params.purpose}`);
|
||||
const pending = Array.from(this.pending.entries())
|
||||
.filter(([key]) => isScopedKey(key))
|
||||
.map(([, value]) => value);
|
||||
if (pending.length > 0) {
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
await this.closeEntries(
|
||||
Array.from(this.cache.entries()).filter(([key]) => isScopedKey(key)),
|
||||
close,
|
||||
params.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
private async closeEntries(
|
||||
entries: Array<[string, T]>,
|
||||
close: (manager: T) => Promise<void>,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
let firstError: unknown;
|
||||
for (const [key, manager] of entries) {
|
||||
try {
|
||||
await close(manager);
|
||||
this.deleteIfCurrent(key, manager);
|
||||
} catch (err) {
|
||||
firstError ??= err;
|
||||
const scope = agentId ? ` for agent ${agentId}` : "";
|
||||
log.warn(`failed to close memory index manager${scope}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (firstError !== undefined) {
|
||||
throw toErrorObject(firstError, "Failed to close memory index manager");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
// Memory Core tests cover manager search orchestration behavior.
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
vi.setConfig({ testTimeout: 240_000 });
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetConfig();
|
||||
});
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embeddedBatchTexts: string[] = [];
|
||||
let embedBatchInputCalls = 0;
|
||||
let providerRuntimeBatchCalls: string[][] = [];
|
||||
let providerRuntimeBatchGate: Promise<void> | null = null;
|
||||
let providerRuntimeBatchErrors: unknown[] = [];
|
||||
let providerRuntimeBatchFailuresRemaining = 0;
|
||||
let providerRuntimeActiveBatchCalls = 0;
|
||||
let providerRuntimeMaxActiveBatchCalls = 0;
|
||||
let providerCloseCalls = 0;
|
||||
let providerCloseFailuresRemaining = 0;
|
||||
let providerCloseFailure: unknown = new Error("provider close failed");
|
||||
let providerCreationFailure: string | null = null;
|
||||
let providerNullResult: string | null = null;
|
||||
let providerCloseGate: Promise<void> | null = null;
|
||||
let providerInitGate: Promise<void> | null = null;
|
||||
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
|
||||
let forceNoProvider = false;
|
||||
|
||||
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
const identityAliasFixture = vi.hoisted(() => ({
|
||||
provider: "identity-alias-test",
|
||||
canonicalModel: "hf:fixture/default-model.gguf",
|
||||
cacheModel: "/fixture/cache/default-model.gguf",
|
||||
}));
|
||||
|
||||
function setMemoryIndexStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
|
||||
function restoreMemoryIndexStateDir(): void {
|
||||
if (originalMemoryIndexStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embeddings.js")>();
|
||||
const embedText = (text: string) => {
|
||||
const lower = text.toLowerCase();
|
||||
const alpha = lower.split("alpha").length - 1;
|
||||
const beta = lower.split("beta").length - 1;
|
||||
const image = lower.split("image").length - 1;
|
||||
const audio = lower.split("audio").length - 1;
|
||||
return [alpha, beta, image, audio];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) =>
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => 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;
|
||||
outputDimensionality?: number;
|
||||
}) => {
|
||||
providerCalls.push({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
});
|
||||
await providerInitGate;
|
||||
if (options.provider === providerCreationFailure) {
|
||||
throw new Error(`provider creation failed: ${options.provider}`);
|
||||
}
|
||||
if (options.provider === providerNullResult) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider,
|
||||
providerUnavailableReason: `provider unavailable: ${options.provider}`,
|
||||
};
|
||||
}
|
||||
if (forceNoProvider) {
|
||||
return {
|
||||
provider: null,
|
||||
requestedProvider: options.provider ?? "auto",
|
||||
providerUnavailableReason: "No API key found for provider",
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "batch-test" ||
|
||||
options.provider === "batch-wide-test" ||
|
||||
options.provider === identityAliasFixture.provider ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
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: {
|
||||
id: providerId,
|
||||
model,
|
||||
close: async () => {
|
||||
providerCloseCalls += 1;
|
||||
await providerCloseGate;
|
||||
if (providerCloseFailuresRemaining > 0) {
|
||||
providerCloseFailuresRemaining -= 1;
|
||||
throw providerCloseFailure;
|
||||
}
|
||||
},
|
||||
embedQuery: async (text: string) => embedText(text),
|
||||
embedBatch: async (texts: string[]) => {
|
||||
embedBatchCalls += 1;
|
||||
embeddedBatchTexts.push(...texts);
|
||||
return texts.map(embedText);
|
||||
},
|
||||
...(providerId === "gemini" || providerId === "fallback-provider"
|
||||
? {
|
||||
embedBatchInputs: async (
|
||||
inputs: Array<{
|
||||
text: string;
|
||||
parts?: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "inline-data"; mimeType: string; data: string }
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
embedBatchInputCalls += 1;
|
||||
return inputs.map((input) => {
|
||||
const inlineData = input.parts?.find((part) => part.type === "inline-data");
|
||||
if (inlineData?.type === "inline-data" && inlineData.data.length > 9000) {
|
||||
throw new Error("payload too large");
|
||||
}
|
||||
const mimeType =
|
||||
inlineData?.type === "inline-data" ? inlineData.mimeType : undefined;
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return [0, 0, 1, 0];
|
||||
}
|
||||
if (mimeType?.startsWith("audio/")) {
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
return embedText(input.text);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(providerId === identityAliasFixture.provider
|
||||
? {
|
||||
runtime: {
|
||||
id: providerId,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.canonicalModel,
|
||||
},
|
||||
indexIdentityAliases: [
|
||||
{
|
||||
model: identityAliasFixture.cacheModel,
|
||||
cacheKeyData: {
|
||||
provider: providerId,
|
||||
model: identityAliasFixture.cacheModel,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: providerId === "batch-test" || providerId === "batch-wide-test"
|
||||
? {
|
||||
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 (providerRuntimeBatchErrors.length > 0) {
|
||||
throw providerRuntimeBatchErrors.shift();
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
const managersForCleanup = new Set<MemoryIndexManager>();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
embedBatchCalls = 0;
|
||||
embeddedBatchTexts = [];
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
providerRuntimeBatchGate = null;
|
||||
providerRuntimeBatchErrors = [];
|
||||
providerRuntimeBatchFailuresRemaining = 0;
|
||||
providerRuntimeActiveBatchCalls = 0;
|
||||
providerRuntimeMaxActiveBatchCalls = 0;
|
||||
providerCloseCalls = 0;
|
||||
providerCloseFailuresRemaining = 0;
|
||||
providerCloseFailure = new Error("provider close failed");
|
||||
providerCreationFailure = null;
|
||||
providerNullResult = null;
|
||||
providerCloseGate = null;
|
||||
providerInitGate = null;
|
||||
providerCalls = [];
|
||||
forceNoProvider = false;
|
||||
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
);
|
||||
});
|
||||
|
||||
function resetManagerForTest(manager: MemoryIndexManager) {
|
||||
// These tests reuse managers for performance. Clear the index + embedding
|
||||
// cache to keep each test fully isolated.
|
||||
const db = (
|
||||
manager as unknown as {
|
||||
db: {
|
||||
exec: (sql: string) => void;
|
||||
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
|
||||
};
|
||||
}
|
||||
).db;
|
||||
for (const table of [
|
||||
"memory_index_sources",
|
||||
"memory_index_chunks",
|
||||
"memory_embedding_cache",
|
||||
"memory_index_chunks_fts",
|
||||
"memory_index_chunks_vec",
|
||||
]) {
|
||||
const existingTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table);
|
||||
if (existingTable?.name === table) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
}
|
||||
(manager as unknown as { dirty: boolean }).dirty = true;
|
||||
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
|
||||
(manager as unknown as { sessionsDirtyFiles: Set<string> }).sessionsDirtyFiles.clear();
|
||||
}
|
||||
|
||||
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
|
||||
|
||||
function createCfg(params: {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
rememberAcrossConversations?: boolean;
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
batchEnabled?: boolean;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
enabled?: boolean;
|
||||
modalities?: Array<"image" | "audio" | "all">;
|
||||
maxFileBytes?: number;
|
||||
};
|
||||
vectorEnabled?: boolean;
|
||||
cacheEnabled?: boolean;
|
||||
minScore?: number;
|
||||
onSearch?: boolean;
|
||||
hybrid?: {
|
||||
enabled: boolean;
|
||||
vectorWeight?: number;
|
||||
textWeight?: number;
|
||||
temporalDecay?: { enabled: boolean };
|
||||
};
|
||||
}): TestCfg {
|
||||
return isolateMemoryManagerTestConfig({
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: {
|
||||
vector: params.vectorEnabled !== undefined ? { enabled: params.vectorEnabled } : {},
|
||||
},
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
batch: { enabled: true },
|
||||
}
|
||||
: undefined,
|
||||
query: { minScore: params.minScore ?? 0 },
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
rememberAcrossConversations:
|
||||
params.rememberAcrossConversations ?? params.sessionMemory ?? false,
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedMemoryIndexSessionTranscript(params: {
|
||||
messages: Array<{
|
||||
content: string;
|
||||
role: "assistant" | "user";
|
||||
senderIsOwner?: boolean;
|
||||
timestamp: number | string;
|
||||
}>;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<void> {
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
const sessionKey = params.sessionKey ?? `agent:main:memory:${params.sessionId}`;
|
||||
// Message timestamps are behavioral inputs; entry freshness only keeps the
|
||||
// fixture out of real session-retention maintenance as wall time advances.
|
||||
const updatedAt = Date.now();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await upsertSessionEntry({
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: params.sessionId,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
for (const message of params.messages) {
|
||||
await appendSessionTranscriptMessageByIdentity({
|
||||
agentId: "main",
|
||||
sessionId: params.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
message: {
|
||||
role: message.role,
|
||||
timestamp: message.timestamp,
|
||||
content: [{ type: "text", text: message.content }],
|
||||
...(message.senderIsOwner ? { __openclaw: { senderIsOwner: true } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function requireManager(
|
||||
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||
missingMessage = "manager missing",
|
||||
): MemoryIndexManager {
|
||||
if (!result.manager) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return result.manager as unknown as MemoryIndexManager;
|
||||
}
|
||||
|
||||
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function getFreshManager(
|
||||
cfg: TestCfg,
|
||||
purpose?: "default" | "status" | "cli",
|
||||
): Promise<MemoryIndexManager> {
|
||||
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main", purpose }));
|
||||
managersForCleanup.add(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) {
|
||||
const manager = await getFreshManager(cfg);
|
||||
try {
|
||||
const status = manager.status();
|
||||
if (!status.fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.sync({ reason: "test" });
|
||||
const results = await manager.search("zebra");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.path).toContain("memory/2026-01-12.md");
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function getFtsSessionManager(params: {
|
||||
stateDirName: string;
|
||||
}): Promise<MemoryIndexManager | null> {
|
||||
forceNoProvider = true;
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
sources: ["memory", "sessions"],
|
||||
sessionMemory: true,
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||
});
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
return manager.status().fts?.available ? manager : null;
|
||||
}
|
||||
|
||||
it("finds keyword matches via hybrid search when query embedding is zero", async () => {
|
||||
await expectHybridKeywordSearchFindsMemory(
|
||||
createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries transient query embedding transport failures during search", async () => {
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
let queryCalls = 0;
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
waitForEmbeddingRetry: (delayMs: number, action: string) => Promise<void>;
|
||||
}
|
||||
).provider = {
|
||||
id: "mock",
|
||||
model: "mock-embed",
|
||||
embedQuery: async () => {
|
||||
queryCalls += 1;
|
||||
if (queryCalls === 1) {
|
||||
throw new Error("TypeError: fetch failed | other side closed");
|
||||
}
|
||||
return [1, 0, 0, 0];
|
||||
},
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [1, 0, 0, 0]),
|
||||
close: async () => {},
|
||||
};
|
||||
(
|
||||
manager as unknown as {
|
||||
waitForEmbeddingRetry: (delayMs: number, action: string) => Promise<void>;
|
||||
}
|
||||
).waitForEmbeddingRetry = async () => {};
|
||||
|
||||
const results = await manager.search("alpha");
|
||||
|
||||
expect(queryCalls).toBe(2);
|
||||
expect(results.some((result) => result.path.endsWith("memory/2026-01-12.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails search after bounded query embedding retries are exhausted", async () => {
|
||||
const cfg = createCfg({
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
let queryCalls = 0;
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
).provider = {
|
||||
id: "mock",
|
||||
model: "mock-embed",
|
||||
embedQuery: async () => {
|
||||
queryCalls += 1;
|
||||
throw new Error("TypeError: fetch failed | other side closed");
|
||||
},
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [1, 0, 0, 0]),
|
||||
close: async () => {},
|
||||
};
|
||||
(
|
||||
manager as unknown as {
|
||||
waitForEmbeddingRetry: (delayMs: number, action: string) => Promise<void>;
|
||||
}
|
||||
).waitForEmbeddingRetry = async () => {};
|
||||
|
||||
await expect(manager.search("alpha")).rejects.toThrow("fetch failed");
|
||||
expect(queryCalls).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves keyword-only hybrid hits when minScore exceeds text weight", async () => {
|
||||
await expectHybridKeywordSearchFindsMemory(
|
||||
createCfg({
|
||||
minScore: 0.35,
|
||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supplements thin strict FTS results for conversational queries", async () => {
|
||||
const cases = [
|
||||
{
|
||||
query: "that thing we discussed about the API",
|
||||
strictFile: "strict-english.md",
|
||||
strictText: "That thing we discussed about the API belongs in the first draft.",
|
||||
recallFile: "recall-english.md",
|
||||
recallText: "API authentication uses short-lived OAuth tokens.",
|
||||
},
|
||||
{
|
||||
query: "ayer hablamos sobre estrategia de despliegue",
|
||||
strictFile: "strict-spanish.md",
|
||||
strictText: "Ayer hablamos sobre estrategia de despliegue para la primera region.",
|
||||
recallFile: "recall-spanish.md",
|
||||
recallText: "La estrategia de despliegue requiere una ventana de mantenimiento.",
|
||||
},
|
||||
] as const;
|
||||
for (const entry of cases) {
|
||||
await fs.writeFile(path.join(memoryDir, entry.strictFile), entry.strictText);
|
||||
await fs.writeFile(path.join(memoryDir, entry.recallFile), entry.recallText);
|
||||
}
|
||||
|
||||
const manager = await getPersistentManager(
|
||||
createCfg({
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||
}),
|
||||
);
|
||||
await manager.sync({ reason: "test" });
|
||||
const provider = Reflect.get(manager, "provider") as {
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
};
|
||||
const embedQuerySpy = vi.spyOn(provider, "embedQuery");
|
||||
|
||||
for (const entry of cases) {
|
||||
const results = await manager.search(entry.query, { maxResults: 6 });
|
||||
expect(results.some((result) => result.path.endsWith(`memory/${entry.recallFile}`))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
expect(embedQuerySpy).toHaveBeenCalledTimes(cases.length);
|
||||
});
|
||||
|
||||
it("bounds per-keyword FTS fallback in provider-backed hybrid search", async () => {
|
||||
const cfg = createCfg({
|
||||
minScore: 0.35,
|
||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||
});
|
||||
const manager = await getPersistentManager(cfg);
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const db = (
|
||||
manager as unknown as {
|
||||
db: {
|
||||
prepare: (sql: string) => unknown;
|
||||
};
|
||||
}
|
||||
).db;
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
let ftsSelects = 0;
|
||||
const prepareSpy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
if (
|
||||
sql.includes("FROM memory_index_chunks_fts") &&
|
||||
sql.includes("WHERE memory_index_chunks_fts MATCH ?")
|
||||
) {
|
||||
ftsSelects += 1;
|
||||
}
|
||||
return originalPrepare(sql);
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await manager.search(
|
||||
"zebra project router gateway session transcript approval command owner workspace token budget retry queue",
|
||||
{ maxResults: 5 },
|
||||
);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.path).toContain("memory/2026-01-12.md");
|
||||
expect(ftsSelects).toBeGreaterThan(1);
|
||||
expect(ftsSelects).toBeLessThanOrEqual(7);
|
||||
} finally {
|
||||
prepareSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves fallback body boosts through hybrid weighting", async () => {
|
||||
const manager = await getPersistentManager(
|
||||
createCfg({
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "body.md"),
|
||||
"Alpha gamma alpha gamma strongest fallback body match.",
|
||||
);
|
||||
await fs.writeFile(path.join(memoryDir, "alpha.md"), "Unrelated path-only candidate.");
|
||||
await manager.sync({ reason: "test" });
|
||||
|
||||
const results = await manager.search("alpha gamma", { maxResults: 2, minScore: 0 });
|
||||
|
||||
expect(results.map((entry) => entry.path)).toEqual(["memory/body.md", "memory/alpha.md"]);
|
||||
expect(results[0]?.score).toBeGreaterThan(results[1]?.score ?? 0);
|
||||
});
|
||||
|
||||
it("bootstraps an empty index on first search so session transcript hits are available", async () => {
|
||||
try {
|
||||
const manager = await getFtsSessionManager({
|
||||
stateDirName: ".state-session-bootstrap",
|
||||
});
|
||||
if (!manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
await seedMemoryIndexSessionTranscript({
|
||||
sessionId: "session-bootstrap",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-07T15:25:04.113Z",
|
||||
content: "The current Project Nebula codename is ORBIT-10.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await manager.search("current Project Nebula codename ORBIT-10", {
|
||||
minScore: 0,
|
||||
maxResults: 3,
|
||||
});
|
||||
|
||||
expect(results[0]?.source).toBe("sessions");
|
||||
expect(results[0]?.snippet).toContain("ORBIT-10");
|
||||
} finally {
|
||||
restoreMemoryIndexStateDir();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps remember-only session transcripts out of ordinary manager searches", async () => {
|
||||
forceNoProvider = true;
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-remember-search-sources"));
|
||||
try {
|
||||
const cfg = createCfg({
|
||||
provider: "none",
|
||||
rememberAcrossConversations: true,
|
||||
minScore: 0,
|
||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
managersForCleanup.add(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await seedMemoryIndexSessionTranscript({
|
||||
sessionId: "remember-only",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-07T15:25:04.113Z",
|
||||
content: "Recall-only canary is NEBULA-47.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await manager.sync({ reason: "test", force: true });
|
||||
|
||||
await expect(
|
||||
manager.search("Recall-only canary NEBULA-47", { minScore: 0 }),
|
||||
).resolves.toEqual([]);
|
||||
const trustedResults = await manager.search("Recall-only canary NEBULA-47", {
|
||||
minScore: 0,
|
||||
sources: ["sessions"],
|
||||
});
|
||||
expect(trustedResults[0]?.source).toBe("sessions");
|
||||
} finally {
|
||||
restoreMemoryIndexStateDir();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns before provider or index bootstrap for a blank query", async () => {
|
||||
const manager = await getPersistentManager(
|
||||
createCfg({ provider: "required-provider", hybrid: { enabled: true } }),
|
||||
);
|
||||
providerCalls = [];
|
||||
|
||||
await expect(manager.search(" \n\t ")).resolves.toStrictEqual([]);
|
||||
|
||||
expect(providerCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("waits for dirty sync before querying", async () => {
|
||||
forceNoProvider = true;
|
||||
const manager = await getPersistentManager(
|
||||
createCfg({ provider: "none", minScore: 0, onSearch: true, hybrid: { enabled: true } }),
|
||||
);
|
||||
await manager.sync({ reason: "test" });
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "search-sync.md"),
|
||||
"Current memory appears only after the dirty search sync.",
|
||||
);
|
||||
await vi.waitFor(() => expect(manager.status().dirty).toBe(true));
|
||||
|
||||
const results = await manager.search("current dirty search sync", {
|
||||
maxResults: 5,
|
||||
minScore: 0,
|
||||
});
|
||||
|
||||
expect(results.some((entry) => entry.path === "memory/search-sync.md")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
// Memory Core plugin module owns public search orchestration.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import {
|
||||
MEMORY_INDEX_FTS_TABLE,
|
||||
MEMORY_INDEX_VECTOR_TABLE,
|
||||
type MemorySearchManager,
|
||||
type MemorySearchResult,
|
||||
type MemorySource,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
mergeHybridResults,
|
||||
selectHybridSearchResults,
|
||||
type HybridSearchResult,
|
||||
} from "./hybrid.js";
|
||||
import { applyImportanceMultiplier } from "./importance.js";
|
||||
import { startAsyncSearchSync } from "./manager-async-state.js";
|
||||
import { MemoryKeywordRetrieval, type KeywordSearchHit } from "./manager-keyword-retrieval.js";
|
||||
import { resolveMemorySearchPreflight } from "./manager-search-preflight.js";
|
||||
import { resolveExactPathSpecificity, searchVector } from "./manager-search.js";
|
||||
import { applyProjectRanking } from "./project-ranking.js";
|
||||
import { applyTemporalDecayToHybridResults } from "./temporal-decay.js";
|
||||
|
||||
const SNIPPET_MAX_CHARS = 700;
|
||||
const VECTOR_TABLE = MEMORY_INDEX_VECTOR_TABLE;
|
||||
const FTS_TABLE = MEMORY_INDEX_FTS_TABLE;
|
||||
const log = createSubsystemLogger("memory");
|
||||
type MemoryIndexSearchOptions = NonNullable<Parameters<MemorySearchManager["search"]>[1]>;
|
||||
|
||||
export abstract class MemorySearchOrchestration extends MemoryKeywordRetrieval {
|
||||
protected abstract sessionWarm: Set<string>;
|
||||
|
||||
protected async warmSession(sessionKey?: string): Promise<void> {
|
||||
if (!this.settings.sync.onSessionStart) {
|
||||
return;
|
||||
}
|
||||
const key = sessionKey?.trim() || "";
|
||||
if (key && this.sessionWarm.has(key)) {
|
||||
return;
|
||||
}
|
||||
void this.sync({ reason: "session-start" }).catch((err: unknown) => {
|
||||
log.warn(`memory sync failed (session-start): ${String(err)}`);
|
||||
});
|
||||
if (key) {
|
||||
this.sessionWarm.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, opts?: MemoryIndexSearchOptions): Promise<MemorySearchResult[]> {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
|
||||
const minScore = opts?.minScore ?? this.settings.query.minScore;
|
||||
const hasActiveProject = (opts?.activeProjectKeys?.length ?? 0) > 0;
|
||||
const candidateMaxResults = hasActiveProject
|
||||
? Math.min(200, Math.max(maxResults, maxResults * 4))
|
||||
: maxResults;
|
||||
const candidateMinScore = hasActiveProject ? minScore / 1.15 : minScore;
|
||||
const results = await this.searchCandidates(normalizedQuery, {
|
||||
...opts,
|
||||
maxResults: candidateMaxResults,
|
||||
minScore: candidateMinScore,
|
||||
});
|
||||
return hasActiveProject
|
||||
? results.filter((entry) => entry.score >= minScore).slice(0, maxResults)
|
||||
: results;
|
||||
}
|
||||
|
||||
private async searchCandidates(
|
||||
normalizedQuery: string,
|
||||
opts?: MemoryIndexSearchOptions,
|
||||
): Promise<MemorySearchResult[]> {
|
||||
return await this.withManagerOperation(async () => {
|
||||
opts?.onDebug?.({ backend: "builtin" });
|
||||
if (this.providerRequirement.mode === "required") {
|
||||
await this.ensureProviderInitialized();
|
||||
this.assertRequiredProviderAvailable("search");
|
||||
}
|
||||
let hasIndexedContent = this.hasIndexedContent();
|
||||
if (!hasIndexedContent) {
|
||||
try {
|
||||
// A fresh process can receive its first search before background watch/session
|
||||
// syncs have built the index. Force one synchronous bootstrap so the first
|
||||
// lookup after restart does not fail closed with empty results.
|
||||
await this.syncAdmitted(
|
||||
{ reason: "search", force: true },
|
||||
{ allowEmbeddingBootstrapFallback: true },
|
||||
);
|
||||
} catch (err) {
|
||||
if (this.providerRequirement.mode === "optional" && this.shouldFallbackOnError(err)) {
|
||||
const failedProvider = this.provider?.id ?? this.settings.provider;
|
||||
await this.retireCurrentProvider().catch((retireErr: unknown) => {
|
||||
const message = redactSensitiveText(formatErrorMessage(retireErr), {
|
||||
mode: "tools",
|
||||
});
|
||||
log.warn(`memory search-bootstrap: failed to retire embedding provider: ${message}`);
|
||||
});
|
||||
this.markEmbeddingBootstrapFailure(err, { provider: failedProvider });
|
||||
await this.syncAdmitted({ reason: "search", force: true }).catch(
|
||||
(fallbackErr: unknown) => {
|
||||
const message = redactSensitiveText(formatErrorMessage(fallbackErr), {
|
||||
mode: "tools",
|
||||
});
|
||||
log.warn(`memory sync failed (search-bootstrap-fallback): ${message}`);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
log.warn(`memory sync failed (search-bootstrap): ${String(err)}`);
|
||||
}
|
||||
}
|
||||
hasIndexedContent = this.hasIndexedContent();
|
||||
}
|
||||
const preflight = resolveMemorySearchPreflight({
|
||||
query: normalizedQuery,
|
||||
hasIndexedContent,
|
||||
});
|
||||
if (!preflight.shouldSearch) {
|
||||
if (this.embeddingBootstrapFailure) {
|
||||
opts?.onDebug?.({
|
||||
backend: "builtin",
|
||||
embeddingBootstrap: this.embeddingBootstrapFailure,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const cleaned = preflight.normalizedQuery;
|
||||
const embeddingBootstrapKeywordOnly = await this.ensureEmbeddingProviderForSearch(
|
||||
opts?.onDebug,
|
||||
);
|
||||
void this.warmSession(opts?.sessionKey);
|
||||
await startAsyncSearchSync({
|
||||
enabled: this.settings.sync.onSearch,
|
||||
dirty: this.dirty,
|
||||
sessionsDirty: this.sessionsDirty,
|
||||
sync: async (params) => await this.syncAdmitted(params),
|
||||
onError: (err) => {
|
||||
log.warn(`memory sync failed (search): ${String(err)}`);
|
||||
},
|
||||
});
|
||||
if (
|
||||
!embeddingBootstrapKeywordOnly &&
|
||||
preflight.shouldInitializeProvider &&
|
||||
!this.provider &&
|
||||
(this.providerLifecycle.mode === "pending" ||
|
||||
(this.providerLifecycle.mode === "degraded" &&
|
||||
this.providerLifecycle.providerId !== this.settings.provider))
|
||||
) {
|
||||
// A failed fallback must yield ownership back to the configured primary.
|
||||
// Reinitialize it before identity validation; leaving the lifecycle pending
|
||||
// makes a valid existing index look mismatched and drops keyword results.
|
||||
this.resetProviderInitializationForRetry();
|
||||
await this.ensureProviderInitialized();
|
||||
}
|
||||
this.assertRequiredProviderAvailable("search");
|
||||
if (
|
||||
!embeddingBootstrapKeywordOnly &&
|
||||
!this.provider &&
|
||||
this.providerLifecycle.mode === "degraded"
|
||||
) {
|
||||
const activatedFallback = await this.activateFallbackProvider(
|
||||
this.providerLifecycle.reason,
|
||||
).catch((fallbackErr: unknown) => {
|
||||
log.warn(
|
||||
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
if (activatedFallback) {
|
||||
this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
});
|
||||
}
|
||||
}
|
||||
const indexIdentity = embeddingBootstrapKeywordOnly
|
||||
? this.refreshKeywordFallbackIndexIdentity()
|
||||
: this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
});
|
||||
if (indexIdentity.status !== "valid") {
|
||||
return [];
|
||||
}
|
||||
const minScore = opts?.minScore ?? this.settings.query.minScore;
|
||||
const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
|
||||
const searchSources =
|
||||
opts?.sources && opts.sources.length > 0
|
||||
? uniqueValues(opts.sources).filter((s) => this.sources.has(s))
|
||||
: undefined;
|
||||
if (
|
||||
opts?.sources &&
|
||||
opts.sources.length > 0 &&
|
||||
(!searchSources || searchSources.length === 0)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// The manager may index recall-only transcripts without making them part of
|
||||
// ordinary searches. Trusted recall passes an explicit source override;
|
||||
// every other caller defaults to the configured search corpus.
|
||||
const sourceFilterList = searchSources ?? this.settings.searchSources;
|
||||
const hybrid = this.settings.query.hybrid;
|
||||
const candidates = Math.min(
|
||||
200,
|
||||
Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)),
|
||||
);
|
||||
|
||||
// FTS-only mode: no embedding provider available
|
||||
if (embeddingBootstrapKeywordOnly || !this.provider) {
|
||||
this.assertRequiredProviderAvailable("search");
|
||||
if (!this.fts.enabled || !this.fts.available) {
|
||||
log.warn("memory search: no provider and FTS unavailable");
|
||||
return [];
|
||||
}
|
||||
|
||||
const keywordResults = await this.searchKeywordWithFallback(
|
||||
cleaned,
|
||||
candidates,
|
||||
{
|
||||
boostFallbackRanking: true,
|
||||
},
|
||||
sourceFilterList,
|
||||
).catch((err: unknown) => {
|
||||
log.warn(`memory search: FTS keyword query failed: ${formatErrorMessage(err)}`);
|
||||
return [];
|
||||
});
|
||||
|
||||
return await this.finalizeKeywordOnlyResults({
|
||||
results: keywordResults,
|
||||
temporalDecay: hybrid.temporalDecay,
|
||||
maxResults,
|
||||
minScore,
|
||||
activeProjectKeys: opts?.activeProjectKeys,
|
||||
});
|
||||
}
|
||||
let semanticProvider = this.provider;
|
||||
let semanticProviderRuntime = this.providerRuntime;
|
||||
let vectorProviderIdentity = {
|
||||
model: semanticProvider.model,
|
||||
aliases: this.resolveProviderIndexIdentities()
|
||||
.slice(1)
|
||||
.map((identity) => identity.model),
|
||||
};
|
||||
|
||||
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
|
||||
const loadKeywordResults = async () =>
|
||||
hybrid.enabled && this.fts.enabled && this.fts.available
|
||||
? await this.searchKeywordWithFallback(
|
||||
cleaned,
|
||||
candidates,
|
||||
{ boostFallbackRanking: true },
|
||||
sourceFilterList,
|
||||
).catch((err: unknown) => {
|
||||
log.warn(
|
||||
`memory search: FTS hybrid keyword query failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
let keywordResults: Awaited<ReturnType<typeof loadKeywordResults>> = [];
|
||||
let queryVec: number[];
|
||||
const releaseSemanticProvider = this.acquireProviderUse(semanticProvider);
|
||||
try {
|
||||
keywordResults = await loadKeywordResults();
|
||||
// lexicalOnly is a reply-path contract: no query embedding, no vector
|
||||
// search, no network. Callers accept keyword-only recall quality.
|
||||
if (opts?.lexicalOnly) {
|
||||
return await this.finalizeKeywordOnlyResults({
|
||||
results: keywordResults,
|
||||
temporalDecay: hybrid.temporalDecay,
|
||||
maxResults,
|
||||
minScore,
|
||||
activeProjectKeys: opts?.activeProjectKeys,
|
||||
});
|
||||
}
|
||||
try {
|
||||
queryVec = await this.embedQueryWithRetry(
|
||||
cleaned,
|
||||
opts?.signal,
|
||||
semanticProvider,
|
||||
false,
|
||||
semanticProviderRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
releaseSemanticProvider();
|
||||
this.markLocalEmbeddingProviderDegraded(err);
|
||||
// An aborted caller already stopped waiting; skip fallback-provider
|
||||
// activation so the abandoned search stops instead of re-embedding.
|
||||
if (opts?.signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const message = formatErrorMessage(err);
|
||||
const activatedFallback = this.shouldFallbackOnError(err)
|
||||
? await this.activateFallbackProvider(message).catch((fallbackErr: unknown) => {
|
||||
log.warn(
|
||||
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
|
||||
);
|
||||
return false;
|
||||
})
|
||||
: false;
|
||||
if (activatedFallback) {
|
||||
if (
|
||||
this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
}).status !== "valid"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (!this.provider) {
|
||||
return [];
|
||||
}
|
||||
semanticProvider = this.provider;
|
||||
semanticProviderRuntime = this.providerRuntime;
|
||||
vectorProviderIdentity = {
|
||||
model: semanticProvider.model,
|
||||
aliases: this.resolveProviderIndexIdentities()
|
||||
.slice(1)
|
||||
.map((identity) => identity.model),
|
||||
};
|
||||
const releaseFallbackProvider = this.acquireProviderUse(semanticProvider);
|
||||
try {
|
||||
keywordResults = await loadKeywordResults();
|
||||
queryVec = await this.embedQueryWithRetry(
|
||||
cleaned,
|
||||
opts?.signal,
|
||||
semanticProvider,
|
||||
false,
|
||||
semanticProviderRuntime,
|
||||
);
|
||||
} catch (fallbackErr) {
|
||||
releaseFallbackProvider();
|
||||
this.markLocalEmbeddingProviderDegraded(fallbackErr);
|
||||
throw fallbackErr;
|
||||
} finally {
|
||||
releaseFallbackProvider();
|
||||
}
|
||||
} else if (!this.provider && this.fts.enabled && this.fts.available) {
|
||||
this.assertRequiredProviderAvailable("search");
|
||||
log.warn(
|
||||
`memory search: embeddings unavailable; using keyword-only results: ${message}`,
|
||||
);
|
||||
return await this.finalizeKeywordOnlyResults({
|
||||
results: keywordResults,
|
||||
temporalDecay: hybrid.temporalDecay,
|
||||
maxResults,
|
||||
minScore,
|
||||
activeProjectKeys: opts?.activeProjectKeys,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseSemanticProvider();
|
||||
}
|
||||
const hasVector = queryVec.some((v) => v !== 0);
|
||||
const vectorResults = hasVector
|
||||
? await this.searchVector(
|
||||
queryVec,
|
||||
candidates,
|
||||
sourceFilterList,
|
||||
vectorProviderIdentity,
|
||||
).catch((err: unknown) => {
|
||||
log.warn(`memory search: vector query failed: ${formatErrorMessage(err)}`);
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) {
|
||||
const decayed = await applyTemporalDecayToHybridResults({
|
||||
results: vectorResults,
|
||||
temporalDecay: hybrid.temporalDecay,
|
||||
workspaceDir: this.workspaceDir,
|
||||
});
|
||||
return applyProjectRanking(applyImportanceMultiplier(decayed), opts?.activeProjectKeys)
|
||||
.filter((entry) => entry.score >= minScore)
|
||||
.slice(0, maxResults);
|
||||
}
|
||||
|
||||
const merged = await this.mergeHybridResults({
|
||||
query: cleaned,
|
||||
vector: vectorResults,
|
||||
keyword: keywordResults,
|
||||
vectorWeight: hybrid.vectorWeight,
|
||||
textWeight: hybrid.textWeight,
|
||||
mmr: hybrid.mmr,
|
||||
temporalDecay: hybrid.temporalDecay,
|
||||
activeProjectKeys: opts?.activeProjectKeys,
|
||||
});
|
||||
return selectHybridSearchResults({
|
||||
merged,
|
||||
keyword: keywordResults,
|
||||
maxResults,
|
||||
minScore,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private hasIndexedContent(): boolean {
|
||||
const chunkRow = this.db.prepare(`SELECT 1 as found FROM memory_index_chunks LIMIT 1`).get() as
|
||||
| {
|
||||
found?: number;
|
||||
}
|
||||
| undefined;
|
||||
if (chunkRow?.found === 1) {
|
||||
return true;
|
||||
}
|
||||
if (!this.fts.enabled || !this.fts.available) {
|
||||
return false;
|
||||
}
|
||||
const ftsRow = this.db.prepare(`SELECT 1 as found FROM ${FTS_TABLE} LIMIT 1`).get() as
|
||||
| {
|
||||
found?: number;
|
||||
}
|
||||
| undefined;
|
||||
return ftsRow?.found === 1;
|
||||
}
|
||||
|
||||
private async searchVector(
|
||||
queryVec: number[],
|
||||
limit: number,
|
||||
sourceFilterList: MemorySource[],
|
||||
providerIdentity: { model: string; aliases: string[] },
|
||||
): Promise<Array<MemorySearchResult & { id: string }>> {
|
||||
const results = await searchVector({
|
||||
db: this.db,
|
||||
vectorTable: VECTOR_TABLE,
|
||||
providerModel: providerIdentity.model,
|
||||
providerModelAliases: providerIdentity.aliases,
|
||||
queryVec,
|
||||
limit,
|
||||
snippetMaxChars: SNIPPET_MAX_CHARS,
|
||||
ensureVectorReady: async (dimensions) => await this.ensureVectorReady(dimensions),
|
||||
sourceFilterVec: this.buildSourceFilter("c", sourceFilterList),
|
||||
sourceFilterChunks: this.buildSourceFilter(undefined, sourceFilterList),
|
||||
});
|
||||
return this.attachRecallMetadata(
|
||||
results.map((entry) => entry as MemorySearchResult & { id: string }),
|
||||
);
|
||||
}
|
||||
|
||||
private mergeHybridResults(params: {
|
||||
query: string;
|
||||
vector: Array<MemorySearchResult & { id: string }>;
|
||||
keyword: KeywordSearchHit[];
|
||||
vectorWeight: number;
|
||||
textWeight: number;
|
||||
mmr?: { enabled: boolean; lambda: number };
|
||||
temporalDecay?: { enabled: boolean; halfLifeDays: number };
|
||||
activeProjectKeys?: readonly string[];
|
||||
}): Promise<HybridSearchResult<MemorySource>[]> {
|
||||
return mergeHybridResults({
|
||||
vector: params.vector.map((r) => ({
|
||||
id: r.id,
|
||||
path: r.path,
|
||||
startLine: r.startLine,
|
||||
endLine: r.endLine,
|
||||
source: r.source,
|
||||
snippet: r.snippet,
|
||||
vectorScore: r.score,
|
||||
importance: r.importance,
|
||||
triggers: r.triggers,
|
||||
projectKey: r.projectKey,
|
||||
exactPathSpecificity: resolveExactPathSpecificity(params.query, r.path),
|
||||
...(r.provenance ? { provenance: r.provenance } : {}),
|
||||
})),
|
||||
keyword: params.keyword.map((r) => ({
|
||||
id: r.id,
|
||||
path: r.path,
|
||||
startLine: r.startLine,
|
||||
endLine: r.endLine,
|
||||
source: r.source,
|
||||
snippet: r.snippet,
|
||||
textScore: r.textScore,
|
||||
importance: r.importance,
|
||||
triggers: r.triggers,
|
||||
projectKey: r.projectKey,
|
||||
rankingScore: r.score,
|
||||
pathScore: r.pathScore,
|
||||
exactPathSpecificity: r.exactPathSpecificity,
|
||||
...(r.provenance ? { provenance: r.provenance } : {}),
|
||||
})),
|
||||
vectorWeight: params.vectorWeight,
|
||||
textWeight: params.textWeight,
|
||||
isNonTextMediaPath: (path) =>
|
||||
classifyMemoryMultimodalPath(path, this.settings.multimodal) !== null,
|
||||
mmr: params.mmr,
|
||||
temporalDecay: params.temporalDecay,
|
||||
activeProjectKeys: params.activeProjectKeys,
|
||||
workspaceDir: this.workspaceDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Memory Core tests cover manager.async search plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { awaitPendingManagerWork, startAsyncSearchSync } from "./manager-async-state.js";
|
||||
import { MemoryIndexManager } from "./manager.js";
|
||||
|
||||
describe("memory search async sync", () => {
|
||||
it("returns before provider or index bootstrap for a blank query", async () => {
|
||||
const manager = Object.create(MemoryIndexManager.prototype) as MemoryIndexManager;
|
||||
const ensureProviderInitialized = vi.fn(async () => {});
|
||||
const assertRequiredProviderAvailable = vi.fn();
|
||||
const hasIndexedContent = vi.fn(() => false);
|
||||
const sync = vi.fn(async () => {});
|
||||
Object.assign(manager as unknown as Record<string, unknown>, {
|
||||
providerRequirement: { mode: "required" },
|
||||
ensureProviderInitialized,
|
||||
assertRequiredProviderAvailable,
|
||||
hasIndexedContent,
|
||||
sync,
|
||||
});
|
||||
|
||||
await expect(manager.search(" \n\t ")).resolves.toStrictEqual([]);
|
||||
expect(ensureProviderInitialized).not.toHaveBeenCalled();
|
||||
expect(assertRequiredProviderAvailable).not.toHaveBeenCalled();
|
||||
expect(hasIndexedContent).not.toHaveBeenCalled();
|
||||
expect(sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for dirty sync before querying", async () => {
|
||||
let releaseSync = () => {};
|
||||
const pendingSync = new Promise<void>((resolve) => {
|
||||
releaseSync = () => resolve();
|
||||
});
|
||||
const syncMock = vi.fn(async () => {
|
||||
return pendingSync;
|
||||
});
|
||||
const queryMock = vi.fn(async () => []);
|
||||
const manager = Object.create(MemoryIndexManager.prototype) as MemoryIndexManager;
|
||||
Object.assign(manager as unknown as Record<string, unknown>, {
|
||||
providerRequirement: { mode: "fts-only", provider: "none" },
|
||||
hasIndexedContent: () => true,
|
||||
settings: {
|
||||
sync: { onSearch: true },
|
||||
query: {
|
||||
minScore: 0,
|
||||
maxResults: 5,
|
||||
hybrid: {
|
||||
enabled: true,
|
||||
candidateMultiplier: 2,
|
||||
temporalDecay: { enabled: false, halfLifeDays: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
warmSession: vi.fn(),
|
||||
ensureProviderInitialized: vi.fn(async () => {}),
|
||||
assertRequiredProviderAvailable: vi.fn(),
|
||||
dirty: true,
|
||||
sessionsDirty: false,
|
||||
syncAdmitted: syncMock,
|
||||
provider: null,
|
||||
providerLifecycle: { mode: "fts-only", reason: "test" },
|
||||
refreshIndexIdentityDirty: () => ({ status: "valid" }),
|
||||
sources: new Set(["memory"]),
|
||||
fts: { enabled: true, available: true },
|
||||
searchKeywordWithFallback: queryMock,
|
||||
workspaceDir: "",
|
||||
});
|
||||
|
||||
const searchPromise = manager.search("current memory");
|
||||
await vi.waitFor(() => expect(syncMock).toHaveBeenCalledWith({ reason: "search" }));
|
||||
expect(queryMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(syncMock).toHaveBeenCalledTimes(1);
|
||||
releaseSync();
|
||||
await searchPromise;
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for in-flight search sync during close", async () => {
|
||||
let releaseSync = () => {};
|
||||
const pendingSync = new Promise<void>((resolve) => {
|
||||
releaseSync = () => resolve();
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
const closePromise = awaitPendingManagerWork({ pendingSync }).then(() => {
|
||||
closed = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(closed).toBe(false);
|
||||
|
||||
releaseSync();
|
||||
await closePromise;
|
||||
});
|
||||
|
||||
it("reports pending sync failures during close", async () => {
|
||||
const onError = vi.fn();
|
||||
const syncError = new Error("sync failed");
|
||||
|
||||
await awaitPendingManagerWork({
|
||||
pendingSync: Promise.reject(syncError),
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(syncError);
|
||||
});
|
||||
|
||||
it("reports pending provider initialization failures during close", async () => {
|
||||
const onError = vi.fn();
|
||||
const providerError = new Error("provider init failed");
|
||||
|
||||
await awaitPendingManagerWork({
|
||||
pendingProviderInit: Promise.reject(providerError),
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(providerError);
|
||||
});
|
||||
|
||||
it("does not report errors for completed pending close work", async () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
await awaitPendingManagerWork({
|
||||
pendingSync: Promise.resolve(),
|
||||
pendingProviderInit: Promise.resolve(),
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips background search sync when search-triggered sync is disabled", async () => {
|
||||
const syncMock = vi.fn(async () => {});
|
||||
await startAsyncSearchSync({
|
||||
enabled: false,
|
||||
dirty: true,
|
||||
sessionsDirty: false,
|
||||
sync: syncMock,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
expect(syncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine
|
||||
import { resolveOpenClawAgentSqlitePath } from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
import { acquireMemoryReindexLock } from "./manager-reindex-lock.js";
|
||||
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
|
||||
type SyncArchiveParams = { needsFullReindex: boolean; targetArchiveFiles?: string[] };
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -170,11 +170,8 @@ vi.mock("./embeddings.js", () => ({
|
||||
}));
|
||||
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
closeAllMemorySearchManagers,
|
||||
getMemorySearchManager,
|
||||
type MemoryIndexManager,
|
||||
} from "./index.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
|
||||
|
||||
describe("memory watcher config", () => {
|
||||
|
||||
@@ -67,6 +67,6 @@ describe("builtin memory search manager", () => {
|
||||
|
||||
await closeMemorySearchManager({ cfg, agentId: " Main " });
|
||||
|
||||
expect(closeMemoryIndexManagersForAgent).toHaveBeenCalledWith({ cfg, agentId: "main" });
|
||||
expect(closeMemoryIndexManagersForAgent).toHaveBeenCalledWith({ agentId: "main" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,6 @@ export async function closeMemorySearchManager(params: {
|
||||
}
|
||||
const { closeMemoryIndexManagersForAgent } = await loadManagerRuntime();
|
||||
await closeMemoryIndexManagersForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: normalizeAgentId(params.agentId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Memory Core helper module supports test manager helpers behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
|
||||
const ensureEmbeddingMocksLoaded = createLazyRuntimeModule(() =>
|
||||
import("./embedding.test-mocks.js").then(() => undefined),
|
||||
|
||||
@@ -562,6 +562,12 @@ describe("test-projects args", () => {
|
||||
forwardedArgs: [],
|
||||
includePatterns: [
|
||||
"extensions/memory-core/src/memory/index.test.ts",
|
||||
"extensions/memory-core/src/memory/manager-keyword-retrieval.test.ts",
|
||||
"extensions/memory-core/src/memory/manager-provider-lifecycle-fallback.test.ts",
|
||||
"extensions/memory-core/src/memory/manager-provider-lifecycle-leases.test.ts",
|
||||
"extensions/memory-core/src/memory/manager-provider-lifecycle.test.ts",
|
||||
"extensions/memory-core/src/memory/manager-registry.test.ts",
|
||||
"extensions/memory-core/src/memory/manager-search-orchestration.test.ts",
|
||||
"extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts",
|
||||
"extensions/memory-core/src/memory/manager.legacy-migration-cleanup.test.ts",
|
||||
"extensions/memory-core/src/memory/manager.reindex-recovery.test.ts",
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
// Check Runtime Sidecar Loaders tests cover check runtime sidecar loaders script behavior.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectTsdownEntrySources,
|
||||
findRuntimeSidecarLoaderViolations,
|
||||
} from "../../scripts/check-runtime-sidecar-loaders.mts";
|
||||
|
||||
function listRuntimeStaticSpecifiers(sourcePath: string): string[] {
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true);
|
||||
return sourceFile.statements.flatMap((statement) => {
|
||||
if (
|
||||
ts.isImportDeclaration(statement) &&
|
||||
ts.isStringLiteral(statement.moduleSpecifier) &&
|
||||
!statement.importClause?.isTypeOnly
|
||||
) {
|
||||
return [statement.moduleSpecifier.text];
|
||||
}
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
statement.moduleSpecifier &&
|
||||
ts.isStringLiteral(statement.moduleSpecifier) &&
|
||||
!statement.isTypeOnly &&
|
||||
!(
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause) &&
|
||||
statement.exportClause.elements.every((element) => element.isTypeOnly)
|
||||
)
|
||||
) {
|
||||
return [statement.moduleSpecifier.text];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLocalSource(importerPath: string, specifier: string): string | null {
|
||||
if (!specifier.startsWith(".")) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolve(dirname(importerPath), specifier);
|
||||
const candidates = [resolved, resolved.replace(/\.js$/, ".ts"), resolve(resolved, "index.ts")];
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function collectRuntimeStaticGraph(entryPath: string): Set<string> {
|
||||
const pending = [entryPath];
|
||||
const visited = new Set<string>();
|
||||
for (const sourcePath of pending) {
|
||||
if (visited.has(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(sourcePath);
|
||||
for (const specifier of listRuntimeStaticSpecifiers(sourcePath)) {
|
||||
const resolved = resolveLocalSource(sourcePath, specifier);
|
||||
if (resolved && !visited.has(resolved)) {
|
||||
pending.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
describe("check-runtime-sidecar-loaders", () => {
|
||||
it("keeps the memory runtime facade out of the manager sidecar graph", () => {
|
||||
const sourcePath = new URL("../../extensions/memory-core/runtime-api.ts", import.meta.url);
|
||||
const runtimeGraph = [...collectRuntimeStaticGraph(sourcePath.pathname)].map((filePath) =>
|
||||
relative(resolve(dirname(sourcePath.pathname), "../.."), filePath),
|
||||
);
|
||||
|
||||
expect(runtimeGraph.filter((filePath) => /(^|\/)manager(?:-|\.)/.test(filePath))).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags hidden createRequire runtime sidecars that are not build entries", () => {
|
||||
const source = `
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
Reference in New Issue
Block a user