import { mkdirSync, rmSync } from
"node:fs" ;
import fs from
"node:fs/promises" ;
import os from
"node:os" ;
import path from
"node:path" ;
import { resolveSessionTranscriptsDirForAgent } from
"openclaw/plugin-sdk/memory-core" ;
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
"vitest" ;
import {
clearMemoryEmbeddingProviders as clearRegistry,
listMemoryEmbeddingProviders as listRegisteredAdapters,
registerMemoryEmbeddingProvider as registerAdapter,
} from
"../../../../src/plugins/memory-embedding-providers.js" ;
import "./test-runtime-mocks.js" ;
import type { MemoryIndexManager } from
"./index.js" ;
import { closeAllMemorySearchManagers, getMemorySearchManager } from
"./index.js" ;
import {
DEFAULT_LOCAL_MODEL,
registerBuiltInMemoryEmbeddingProviders,
} from
"./provider-adapters.js" ;
let embedBatchCalls =
0 ;
let embedBatchInputCalls =
0 ;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: numb
er }> = [];
let forceNoProvider = false ;
vi.mock("./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 {
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
outputDimensionality?: number;
}) => {
providerCalls.push({
provider: options.provider,
model: options.model,
outputDimensionality: options.outputDimensionality,
});
if (forceNoProvider) {
return {
provider: null ,
requestedProvider: options.provider ?? "auto" ,
providerUnavailableReason: "No API key found for provider" ,
};
}
const providerId = options.provider === "gemini" ? "gemini" : "mock" ;
const model = options.model ?? "mock-embed" ;
return {
requestedProvider: options.provider ?? "openai" ,
provider: {
id: providerId,
model,
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1 ;
return texts.map(embedText);
},
...(providerId === "gemini"
? {
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 === "gemini"
? {
runtime: {
id: "gemini" ,
cacheKeyData: {
provider: "gemini" ,
baseUrl: "https://generativelanguage.googleapis.com/v1beta ",
model,
outputDimensionality: options.outputDimensionality,
headers: [],
},
},
}
: {}),
};
},
};
});
describe("memory index" , () => {
it("registers the builtin local embedding provider" , () => {
const adapter = listRegisteredAdapters().find((entry) => entry.id === "local" );
expect(adapter).toBeDefined();
expect(adapter).toEqual(
expect.objectContaining({
id: "local" ,
defaultModel: DEFAULT_LOCAL_MODEL,
}),
);
});
let fixtureRoot = "" ;
let workspaceDir = "" ;
let memoryDir = "" ;
let indexVectorPath = "" ;
let indexMainPath = "" ;
let indexMultimodalPath = "" ;
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" );
indexMainPath = path.join(workspaceDir, "index-main.sqlite" );
indexVectorPath = path.join(workspaceDir, "index-vector.sqlite" );
indexMultimodalPath = path.join(workspaceDir, "index-multimodal.sqlite" );
});
afterAll(async () => {
await Promise.all(Array.from(managersForCleanup).map((manager) => manager.close()));
await fs.rm(fixtureRoot, { recursive: true , force: true });
});
afterEach(async () => {
await closeAllMemorySearchManagers();
clearRegistry();
managersForCleanup.clear();
});
beforeEach(async () => {
// Perf: most suites don't need atomic swap behavior for full reindexes.
// Keep atomic reindex tests on the safe path.
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX" , "1" );
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
embedBatchCalls = 0 ;
embedBatchInputCalls = 0 ;
providerCalls = [];
forceNoProvider = false ;
rmSync(workspaceDir, { recursive: true , force: true });
mkdirSync(memoryDir, { recursive: true });
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;
(manager as unknown as { resetIndex: () => void }).resetIndex();
const embeddingCacheTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?" )
.get("embedding_cache" );
if (embeddingCacheTable?.name === "embedding_cache" ) {
db.exec("DELETE FROM embedding_cache" );
}
(manager as unknown as { dirty: boolean }).dirty = true ;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false ;
}
type TestCfg = Parameters<typeof getMemorySearchManager>[0 ]["cfg" ];
function createCfg(params: {
storePath: string;
extraPaths?: string[];
sources?: Array<"memory" | "sessions" >;
sessionMemory?: boolean ;
provider?: "openai" | "gemini" ;
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 };
}): TestCfg {
return {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: params.provider ?? "openai" ,
model: params.model ?? "mock-embed" ,
outputDimensionality: params.outputDimensionality,
store: { path: params.storePath, vector: { enabled: params.vectorEnabled ?? false } },
// Perf: keep test indexes to a single chunk to reduce sqlite work.
chunking: { tokens: 4000 , overlap: 0 },
sync: { watch: false , onSessionStart: false , onSearch: params.onSearch ?? true },
query: {
minScore: params.minScore ?? 0 ,
hybrid: params.hybrid ?? { enabled: false },
},
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
experimental: { sessionMemory: params.sessionMemory ?? false },
},
},
list: [{ id: "main" , default : true }],
},
};
}
function requireManager(
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
missingMessage = "manager missing" ,
): MemoryIndexManager {
expect(result.manager).not.toBeNull();
if (!result.manager) {
throw new Error(missingMessage);
}
return result.manager 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): Promise<MemoryIndexManager> {
const { getRequiredMemoryIndexManager } = await import ("./test-manager-helpers.js" );
return await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
}
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;
storeFileName: string;
}): Promise<MemoryIndexManager | null > {
forceNoProvider = true ;
vi.stubEnv("OPENCLAW_STATE_DIR" , path.join(workspaceDir, params.stateDirName));
const cfg = createCfg({
storePath: path.join(workspaceDir, params.storeFileName),
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.skip("indexes memory files and searches" , async () => {
const cfg = createCfg({
storePath: indexMainPath,
hybrid: { enabled: true , vectorWeight: 0 .5 , textWeight: 0 .5 },
});
const manager = await getFreshManager(cfg);
try {
await manager.sync({ reason: "test" });
const results = await manager.search("alpha" );
expect(results.length).toBeGreaterThan(0 );
expect(results[0 ]?.path).toContain("memory/2026-01-12.md" );
const status = manager.status();
expect(status.sourceCounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
source: "memory" ,
files: status.files,
chunks: status.chunks,
}),
]),
);
} finally {
await manager.close?.();
}
});
it("indexes multimodal image and audio files from extra paths with Gemini structured inputs" , async () => {
const mediaDir = path.join(workspaceDir, "media-memory" );
await fs.mkdir(mediaDir, { recursive: true });
await fs.writeFile(path.join(mediaDir, "diagram.png" ), Buffer.from("png" ));
await fs.writeFile(path.join(mediaDir, "meeting.wav" ), Buffer.from("wav" ));
const cfg = createCfg({
storePath: indexMultimodalPath,
provider: "gemini" ,
model: "gemini-embedding-2-preview" ,
extraPaths: [mediaDir],
multimodal: { enabled: true , modalities: ["image" , "audio" ] },
});
const manager = await getPersistentManager(cfg);
await manager.sync({ reason: "test" });
expect(embedBatchInputCalls).toBeGreaterThan(0 );
const imageResults = await manager.search("image" );
expect(imageResults.some((result) => result.path.endsWith("diagram.png" ))).toBe(true );
const audioResults = await manager.search("audio" );
expect(audioResults.some((result) => result.path.endsWith("meeting.wav" ))).toBe(true );
});
it.skip("finds keyword matches via hybrid search when query embedding is zero" , async () => {
await expectHybridKeywordSearchFindsMemory(
createCfg({
storePath: indexMainPath,
hybrid: { enabled: true , vectorWeight: 0 , textWeight: 1 },
}),
);
});
it.skip("preserves keyword-only hybrid hits when minScore exceeds text weight" , async () => {
await expectHybridKeywordSearchFindsMemory(
createCfg({
storePath: indexMainPath,
minScore: 0 .35 ,
hybrid: { enabled: true , vectorWeight: 0 .7 , textWeight: 0 .3 },
}),
);
});
it("reports vector availability after probe" , async () => {
const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true });
const manager = await getPersistentManager(cfg);
const available = await manager.probeVectorAvailability();
const status = manager.status();
expect(status.vector?.enabled).toBe(true );
expect(typeof status.vector?.available).toBe("boolean" );
expect(status.vector?.available).toBe(available);
});
it("builds FTS index and returns search results when no embedding provider is available" , async () => {
forceNoProvider = true ;
const cfg = createCfg({
storePath: path.join(workspaceDir, "index-fts-only.sqlite" ),
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("prefers exact session transcript hits in FTS-only mode" , async () => {
try {
const manager = await getFtsSessionManager({
stateDirName: ".state-session-ranking" ,
storeFileName: "index-fts-session-ranking.sqlite" ,
});
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 sessionsDir = resolveSessionTranscriptsDirForAgent("main" );
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "session-ranking.jsonl" );
const now = Date.parse("2026-04-07T15:25:04.113Z" );
await fs.writeFile(
transcriptPath,
[
JSON.stringify({
type: "session" ,
id: "session-ranking" ,
timestamp: new Date(now - 60 _000 ).toISOString(),
}),
JSON.stringify({
type: "message" ,
message: {
role: "user" ,
timestamp: new Date(now - 30 _000 ).toISOString(),
content: [{ type: "text" , text: "What is the current Project Nebula codename?" }],
},
}),
JSON.stringify({
type: "message" ,
message: {
role: "assistant" ,
timestamp: new Date(now).toISOString(),
content: [{ type: "text" , text: "The current Project Nebula codename is ORBIT-10." }],
},
}),
].join("\n" ) + "\n" ,
"utf8" ,
);
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" );
} finally {
vi.unstubAllEnvs();
}
});
it("bootstraps an empty index on first search so session transcript hits are available" , async () => {
try {
const manager = await getFtsSessionManager({
stateDirName: ".state-session-bootstrap" ,
storeFileName: "index-fts-session-bootstrap.sqlite" ,
});
if (!manager) {
return ;
}
const sessionsDir = resolveSessionTranscriptsDirForAgent("main" );
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "session-bootstrap.jsonl" );
await fs.writeFile(
transcriptPath,
[
JSON.stringify({
type: "session" ,
id: "session-bootstrap" ,
timestamp: "2026-04-07T15:24:04.113Z" ,
}),
JSON.stringify({
type: "message" ,
message: {
role: "assistant" ,
timestamp: "2026-04-07T15:25:04.113Z" ,
content: [{ type: "text" , text: "The current Project Nebula codename is ORBIT-10." }],
},
}),
].join("\n" ) + "\n" ,
"utf8" ,
);
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 {
vi.unstubAllEnvs();
}
});
});
Messung V0.5 in Prozent C=98 H=98 G=97
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland