diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 31014a024c9..3aeefff3aaf 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -384,6 +384,129 @@ describe("memory plugin e2e", () => { } }); + test("uses live runtime config to skip auto-recall after registration", async () => { + const embeddingsCreate = vi.fn(async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + })); + const ensureGlobalUndiciEnvProxyDispatcher = vi.fn(); + const loadLanceDbModule = vi.fn(async () => ({ + connect: vi.fn(async () => ({ + tableNames: vi.fn(async () => ["memories"]), + openTable: vi.fn(async () => ({ + vectorSearch: vi.fn(() => ({ limit: vi.fn(() => ({ toArray: vi.fn(async () => []) })) })), + countRows: vi.fn(async () => 0), + add: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + })), + })), + })); + let configFile: Record = { + plugins: { + entries: { + "memory-lancedb": { + config: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: true, + }, + }, + }, + }, + }; + + vi.resetModules(); + vi.doMock("openclaw/plugin-sdk/runtime-env", () => ({ + ensureGlobalUndiciEnvProxyDispatcher, + })); + vi.doMock("openai", () => ({ + default: class MockOpenAI { + embeddings = { create: embeddingsCreate }; + }, + })); + vi.doMock("./lancedb-runtime.js", () => ({ + loadLanceDbModule, + })); + + try { + const { default: dynamicMemoryPlugin } = await import("./index.js"); + const on = vi.fn(); + const mockApi = { + id: "memory-lancedb", + name: "Memory (LanceDB)", + source: "test", + config: {}, + pluginConfig: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: true, + }, + runtime: { + config: { + loadConfig: () => configFile, + }, + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerTool: vi.fn(), + registerCli: vi.fn(), + registerService: vi.fn(), + on, + resolvePath: (p: string) => p, + }; + + dynamicMemoryPlugin.register(mockApi as any); + + configFile = { + plugins: { + entries: { + "memory-lancedb": { + config: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: false, + }, + }, + }, + }, + }; + + const beforePromptBuild = on.mock.calls.find( + ([hookName]) => hookName === "before_prompt_build", + )?.[1]; + expect(beforePromptBuild).toBeTypeOf("function"); + + const result = await beforePromptBuild?.( + { prompt: "what editor should i use?", messages: [] }, + {}, + ); + + expect(result).toBeUndefined(); + expect(embeddingsCreate).not.toHaveBeenCalled(); + expect(loadLanceDbModule).not.toHaveBeenCalled(); + } finally { + vi.doUnmock("openclaw/plugin-sdk/runtime-env"); + vi.doUnmock("openai"); + vi.doUnmock("./lancedb-runtime.js"); + vi.resetModules(); + } + }); + test("runs auto-capture through the registered agent_end hook", async () => { const embeddingsCreate = vi.fn(async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }], @@ -492,6 +615,131 @@ describe("memory plugin e2e", () => { } }); + test("uses live runtime config to skip auto-capture after registration", async () => { + const embeddingsCreate = vi.fn(async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + })); + const ensureGlobalUndiciEnvProxyDispatcher = vi.fn(); + const add = vi.fn(async () => undefined); + const loadLanceDbModule = vi.fn(async () => ({ + connect: vi.fn(async () => ({ + tableNames: vi.fn(async () => ["memories"]), + openTable: vi.fn(async () => ({ + vectorSearch: vi.fn(() => ({ limit: vi.fn(() => ({ toArray: vi.fn(async () => []) })) })), + countRows: vi.fn(async () => 0), + add, + delete: vi.fn(async () => undefined), + })), + })), + })); + let configFile: Record = { + plugins: { + entries: { + "memory-lancedb": { + config: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: true, + autoRecall: false, + }, + }, + }, + }, + }; + + vi.resetModules(); + vi.doMock("openclaw/plugin-sdk/runtime-env", () => ({ + ensureGlobalUndiciEnvProxyDispatcher, + })); + vi.doMock("openai", () => ({ + default: class MockOpenAI { + embeddings = { create: embeddingsCreate }; + }, + })); + vi.doMock("./lancedb-runtime.js", () => ({ + loadLanceDbModule, + })); + + try { + const { default: dynamicMemoryPlugin } = await import("./index.js"); + const on = vi.fn(); + const mockApi = { + id: "memory-lancedb", + name: "Memory (LanceDB)", + source: "test", + config: {}, + pluginConfig: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: true, + autoRecall: false, + }, + runtime: { + config: { + loadConfig: () => configFile, + }, + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerTool: vi.fn(), + registerCli: vi.fn(), + registerService: vi.fn(), + on, + resolvePath: (p: string) => p, + }; + + dynamicMemoryPlugin.register(mockApi as any); + + configFile = { + plugins: { + entries: { + "memory-lancedb": { + config: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: false, + }, + }, + }, + }, + }; + + const agentEnd = on.mock.calls.find(([hookName]) => hookName === "agent_end")?.[1]; + expect(agentEnd).toBeTypeOf("function"); + + await agentEnd?.( + { + success: true, + messages: [{ role: "user", content: "I prefer Helix for editing code every day." }], + }, + {}, + ); + + expect(embeddingsCreate).not.toHaveBeenCalled(); + expect(loadLanceDbModule).not.toHaveBeenCalled(); + expect(add).not.toHaveBeenCalled(); + } finally { + vi.doUnmock("openclaw/plugin-sdk/runtime-env"); + vi.doUnmock("openai"); + vi.doUnmock("./lancedb-runtime.js"); + vi.resetModules(); + } + }); + test("passes configured dimensions to OpenAI embeddings API", async () => { const embeddingsCreate = vi.fn(async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }], diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index bfe508fc90d..911d1d46eeb 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -40,6 +40,12 @@ type MemorySearchResult = { score: number; }; +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + // ============================================================================ // LanceDB Provider // ============================================================================ @@ -300,6 +306,33 @@ export default definePluginEntry({ const vectorDim = dimensions ?? vectorDimsForModel(model); const db = new MemoryDB(resolvedDbPath, vectorDim, cfg.storageOptions); const embeddings = new Embeddings(apiKey, model, baseUrl, dimensions); + const resolveCurrentHookConfig = () => { + const runtimeConfig = api.runtime.config?.loadConfig?.(); + const runtimePlugins = asRecord(asRecord(runtimeConfig)?.plugins); + const runtimeEntries = asRecord(runtimePlugins?.entries); + const runtimePluginConfig = asRecord(runtimeEntries?.["memory-lancedb"])?.config; + if (!runtimePluginConfig) { + return cfg; + } + return memoryConfigSchema.parse({ + embedding: { + apiKey: cfg.embedding.apiKey, + model: cfg.embedding.model, + ...(cfg.embedding.baseUrl ? { baseUrl: cfg.embedding.baseUrl } : {}), + ...(typeof cfg.embedding.dimensions === "number" + ? { dimensions: cfg.embedding.dimensions } + : {}), + ...asRecord(asRecord(runtimePluginConfig)?.embedding), + }, + ...(cfg.dreaming ? { dreaming: cfg.dreaming } : {}), + dbPath: cfg.dbPath, + autoCapture: cfg.autoCapture, + autoRecall: cfg.autoRecall, + captureMaxChars: cfg.captureMaxChars, + ...(cfg.storageOptions ? { storageOptions: cfg.storageOptions } : {}), + ...asRecord(runtimePluginConfig), + }); + }; api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`); @@ -542,6 +575,10 @@ export default definePluginEntry({ // Auto-recall: inject relevant memories during prompt build if (cfg.autoRecall) { api.on("before_prompt_build", async (event) => { + const currentCfg = resolveCurrentHookConfig(); + if (!currentCfg.autoRecall) { + return undefined; + } if (!event.prompt || event.prompt.length < 5) { return undefined; } @@ -571,6 +608,10 @@ export default definePluginEntry({ // Auto-capture: analyze and store important information after agent ends if (cfg.autoCapture) { api.on("agent_end", async (event) => { + const currentCfg = resolveCurrentHookConfig(); + if (!currentCfg.autoCapture) { + return; + } if (!event.success || !event.messages || event.messages.length === 0) { return; } @@ -618,7 +659,7 @@ export default definePluginEntry({ // Filter for capturable content const toCapture = texts.filter( - (text) => text && shouldCapture(text, { maxChars: cfg.captureMaxChars }), + (text) => text && shouldCapture(text, { maxChars: currentCfg.captureMaxChars }), ); if (toCapture.length === 0) { return;