diff --git a/CHANGELOG.md b/CHANGELOG.md index 634b9262499..20037114fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant. - ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao. - Diffs/viewer: re-read remote viewer access policy from live runtime config on each request, so toggling `plugins.entries.diffs.config.security.allowRemoteViewer` closes proxied viewer access immediately instead of waiting for a restart. Thanks @vincentkoc. +- Memory/LanceDB: stop resurrecting removed live `memory-lancedb` hook config from startup snapshots, so deleting or disabling the plugin entry shuts off auto-recall and auto-capture without a restart. Thanks @vincentkoc. - Agents/subagents: drop bare `NO_REPLY` from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana. - Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads. - CLI/channels: skip and cache bundled channel plugin, setup, and secrets load failures during read-only discovery, so one broken unused bundled channel cannot crash `openclaw status` or bootstrap secret scans. diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 31a965db28e..ed2621d4a9a 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -511,6 +511,117 @@ describe("memory plugin e2e", () => { } }); + test("fails closed for auto-recall when the live plugin entry is removed", 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: {}, + }, + }; + + 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 after memory is removed?", 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] }], @@ -744,6 +855,119 @@ describe("memory plugin e2e", () => { } }); + test("fails closed for auto-capture when the live plugin entry is removed", 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: {}, + }, + }; + + 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 b9e5de343ba..29670e3072a 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto"; import type * as LanceDB from "@lancedb/lancedb"; import { Type } from "@sinclair/typebox"; import OpenAI from "openai"; -import { resolvePluginConfigObject } from "openclaw/plugin-sdk/config-runtime"; +import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/config-runtime"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; @@ -306,15 +306,19 @@ export default definePluginEntry({ const dbPath = cfg.dbPath!; const resolvedDbPath = dbPath.includes("://") ? dbPath : api.resolvePath(dbPath); const { model, dimensions, apiKey, baseUrl } = cfg.embedding; + const disabledHookCfg = { ...cfg, autoCapture: false, autoRecall: false }; 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 runtimePluginConfig = resolvePluginConfigObject(runtimeConfig, "memory-lancedb"); + const runtimePluginConfig = resolveLivePluginConfigObject( + api.runtime.config?.loadConfig, + "memory-lancedb", + api.pluginConfig as Record, + ); if (!runtimePluginConfig) { - return cfg; + return disabledHookCfg; } return memoryConfigSchema.parse({ embedding: { diff --git a/src/plugin-sdk/config-runtime.test.ts b/src/plugin-sdk/config-runtime.test.ts index 50b648d6ac7..32e41ad1baf 100644 --- a/src/plugin-sdk/config-runtime.test.ts +++ b/src/plugin-sdk/config-runtime.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolvePluginConfigObject, type OpenClawConfig } from "./config-runtime.js"; +import { + resolveLivePluginConfigObject, + resolvePluginConfigObject, + type OpenClawConfig, +} from "./config-runtime.js"; describe("resolvePluginConfigObject", () => { it("returns the plugin config object for a configured plugin entry", () => { @@ -45,3 +49,29 @@ describe("resolvePluginConfigObject", () => { expect(resolvePluginConfigObject(undefined, "demo-plugin")).toBeUndefined(); }); }); + +describe("resolveLivePluginConfigObject", () => { + it("falls back to startup config only when no runtime loader exists", () => { + expect( + resolveLivePluginConfigObject(undefined, "demo-plugin", { + enabled: true, + }), + ).toEqual({ + enabled: true, + }); + }); + + it("fails closed when the runtime loader exists but the plugin entry is missing", () => { + const config = { + plugins: { + entries: {}, + }, + } as unknown as OpenClawConfig; + + expect( + resolveLivePluginConfigObject(() => config, "demo-plugin", { + enabled: true, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index e0f944c20b1..5fccecbaab3 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -34,6 +34,17 @@ export function resolvePluginConfigObject( : undefined; } +export function resolveLivePluginConfigObject( + runtimeConfigLoader: (() => OpenClawConfig | undefined) | undefined, + pluginId: string, + startupPluginConfig?: Record, +): Record | undefined { + if (typeof runtimeConfigLoader !== "function") { + return startupPluginConfig; + } + return resolvePluginConfigObject(runtimeConfigLoader(), pluginId); +} + export { resolveDefaultAgentId } from "../agents/agent-scope.js"; export { clearRuntimeConfigSnapshot, diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 5b79c838a73..f92d92fb0ee 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -60,10 +60,7 @@ const BUNDLED_LIVE_CONFIG_HOOK_GUARDS = { "resolveMemoryCorePluginConfig(startupCfg)", "api.runtime.config?.loadConfig?.() ?? api.config", ], - "extensions/memory-lancedb/index.ts": [ - 'resolvePluginConfigObject(runtimeConfig, "memory-lancedb")', - "api.runtime.config?.loadConfig?.()", - ], + "extensions/memory-lancedb/index.ts": ["resolveLivePluginConfigObject(", '"memory-lancedb"'], "extensions/skill-workshop/index.ts": [ 'resolvePluginConfigObject(runtimeConfig, "skill-workshop")', 'typeof api.runtime.config?.loadConfig === "function"',