diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a547839f9..8713c1894cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - fix(infra): block ambient Homebrew env vars from brew resolution. (#74463) Thanks @pgondhi987. - Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere. - Security/Windows: ignore workspace `.env` system-path variables and resolve stale-process `taskkill.exe` from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987. +- CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc. - Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc. - Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf. - Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps. diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 8dfce4de2c7..962df8b1d47 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -57,7 +57,7 @@ export const writePersistedInstalledPluginIndexInstallRecords: AsyncUnknownMock ); }, ); -const loadPluginManifestRegistry: UnknownMock = vi.fn(); +export const loadPluginManifestRegistry: UnknownMock = vi.fn(); export const buildPluginSnapshotReport: UnknownMock = vi.fn(); export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn(); export const buildPluginInspectReport: UnknownMock = vi.fn(); @@ -288,7 +288,10 @@ vi.mock("../plugins/status.js", () => ({ })); vi.mock("../plugins/plugin-registry.js", () => ({ - loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }), + loadPluginManifestRegistryForPluginRegistry: ((...args: unknown[]) => + invokeMock(loadPluginManifestRegistry, ...args)) as ( + ...args: unknown[] + ) => unknown, inspectPluginRegistry: (( ...args: Parameters<(typeof import("../plugins/plugin-registry.js"))["inspectPluginRegistry"]> ) => diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 583b498f3e4..390798dc0db 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -1,8 +1,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js"; -import { applyExclusiveSlotSelection, slotKeysForPluginKind } from "../plugins/slots.js"; -import { buildPluginDiagnosticsReport, buildPluginSnapshotReport } from "../plugins/status.js"; +import type { PluginKind } from "../plugins/plugin-kind.types.js"; +import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js"; +import { applyExclusiveSlotSelection } from "../plugins/slots.js"; +import { buildPluginDiagnosticsReport } from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; @@ -17,6 +19,59 @@ export const quietPluginJsonLogger: PluginLogger = { error: () => undefined, }; +type SlotSelectionPlugin = { + id: string; + kind?: PluginKind | PluginKind[]; +}; + +type SlotSelectionRegistry = { + plugins: readonly SlotSelectionPlugin[]; +}; + +function mergeRuntimeKinds( + report: SlotSelectionRegistry, + runtimeReport: SlotSelectionRegistry, +): SlotSelectionRegistry { + const runtimeKinds = new Map( + runtimeReport.plugins + .filter((plugin) => plugin.kind) + .map((plugin) => [plugin.id, plugin.kind] as const), + ); + return { + plugins: report.plugins.map((plugin) => { + if (plugin.kind) { + return plugin; + } + const runtimeKind = runtimeKinds.get(plugin.id); + return runtimeKind ? { ...plugin, kind: runtimeKind } : plugin; + }), + }; +} + +function loadRuntimeKindReportForPlugins(config: OpenClawConfig, pluginIds: readonly string[]) { + return buildPluginDiagnosticsReport({ + config, + onlyPluginIds: [...pluginIds], + }); +} + +function buildSlotSelectionRegistry( + config: OpenClawConfig, + pluginId: string, +): SlotSelectionRegistry { + const registry = loadPluginManifestRegistryForPluginRegistry({ + config, + includeDisabled: true, + pluginIds: [pluginId], + }); + return { + plugins: registry.plugins.map((plugin) => ({ + id: plugin.id, + kind: plugin.kind, + })), + }; +} + export function resolveFileNpmSpecToLocalPath( raw: string, ): { ok: true; path: string } | { ok: false; error: string } | null { @@ -47,34 +102,20 @@ export function applySlotSelectionForPlugin( config: OpenClawConfig, pluginId: string, ): { config: OpenClawConfig; warnings: string[] } { - const report = buildPluginSnapshotReport({ config }); + const report = buildSlotSelectionRegistry(config, pluginId); const plugin = report.plugins.find((entry) => entry.id === pluginId); if (!plugin) { return { config, warnings: [] }; } - if ( - plugin.kind && - slotKeysForPluginKind(plugin.kind).length > 0 && - report.plugins.some((entry) => entry.id !== plugin.id && !entry.kind) - ) { - const runtimeReport = buildPluginDiagnosticsReport({ config }); - const result = applyExclusiveSlotSelection({ - config, - selectedId: plugin.id, - selectedKind: plugin.kind, - registry: runtimeReport, - }); - return { config: result.config, warnings: result.warnings }; - } if (!plugin.kind) { - const runtimeReport = buildPluginDiagnosticsReport({ config }); + const runtimeReport = loadRuntimeKindReportForPlugins(config, [plugin.id]); const runtimePlugin = runtimeReport.plugins.find((entry) => entry.id === plugin.id); if (runtimePlugin?.kind) { const result = applyExclusiveSlotSelection({ config, selectedId: runtimePlugin.id, selectedKind: runtimePlugin.kind, - registry: runtimeReport, + registry: mergeRuntimeKinds(report, runtimeReport), }); return { config: result.config, warnings: result.warnings }; } diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 232741445b1..5ec518cf19c 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -3,8 +3,8 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyExclusiveSlotSelection, buildPluginDiagnosticsReport, - buildPluginSnapshotReport, enablePluginInConfig, + loadPluginManifestRegistry, refreshPluginRegistry, resetPluginsCliTestState, writeConfigFile, @@ -111,7 +111,7 @@ describe("persistPluginInstall", () => { expect(next).toEqual(enabledConfig); }); - it("falls back to runtime kind registry cleanup when metadata omits kind", async () => { + it("scopes runtime kind lookup to the selected plugin when metadata omits kind", async () => { const { persistPluginInstall } = await import("./plugins-install-persist.js"); const baseConfig = { plugins: { @@ -129,15 +129,12 @@ describe("persistPluginInstall", () => { }, } as OpenClawConfig; enablePluginInConfig.mockReturnValue({ config: enabledConfig }); - buildPluginSnapshotReport.mockReturnValue({ - plugins: [{ id: "legacy-memory-a" }, { id: "legacy-memory" }], + loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "legacy-memory" }], diagnostics: [], }); - buildPluginDiagnosticsReport.mockReturnValue({ - plugins: [ - { id: "legacy-memory-a", kind: "memory" }, - { id: "legacy-memory", kind: "memory" }, - ], + buildPluginDiagnosticsReport.mockReturnValueOnce({ + plugins: [{ id: "legacy-memory", kind: "memory" }], diagnostics: [], }); applyExclusiveSlotSelection.mockImplementation(((params: { @@ -148,19 +145,12 @@ describe("persistPluginInstall", () => { }) => { expect(params.selectedId).toBe("legacy-memory"); expect(params.selectedKind).toBe("memory"); - expect(params.registry?.plugins).toEqual([ - { id: "legacy-memory-a", kind: "memory" }, - { id: "legacy-memory", kind: "memory" }, - ]); + expect(params.registry?.plugins).toEqual([{ id: "legacy-memory", kind: "memory" }]); return { config: { ...params.config, plugins: { ...params.config.plugins, - entries: { - ...params.config.plugins?.entries, - "legacy-memory-a": { enabled: false }, - }, slots: { ...params.config.plugins?.slots, memory: "legacy-memory", @@ -185,14 +175,21 @@ describe("persistPluginInstall", () => { }, }); + expect(buildPluginDiagnosticsReport).toHaveBeenCalledTimes(1); expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({ config: enabledConfig, + onlyPluginIds: ["legacy-memory"], }); - expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(false); + expect(loadPluginManifestRegistry).toHaveBeenCalledWith({ + config: enabledConfig, + includeDisabled: true, + pluginIds: ["legacy-memory"], + }); + expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true); expect(next.plugins?.slots?.memory).toBe("legacy-memory"); }); - it("uses runtime registry cleanup when a manifest-kind plugin has runtime-kind siblings", async () => { + it("uses cold metadata for manifest-kind slot selection without loading runtime siblings", async () => { const { persistPluginInstall } = await import("./plugins-install-persist.js"); const baseConfig = { plugins: { @@ -210,15 +207,8 @@ describe("persistPluginInstall", () => { }, } as OpenClawConfig; enablePluginInConfig.mockReturnValue({ config: enabledConfig }); - buildPluginSnapshotReport.mockReturnValue({ - plugins: [{ id: "legacy-memory-a" }, { id: "memory-b", kind: "memory" }], - diagnostics: [], - }); - buildPluginDiagnosticsReport.mockReturnValue({ - plugins: [ - { id: "legacy-memory-a", kind: "memory" }, - { id: "memory-b", kind: "memory" }, - ], + loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "memory-b", kind: "memory" }], diagnostics: [], }); applyExclusiveSlotSelection.mockImplementation(((params: { @@ -229,19 +219,12 @@ describe("persistPluginInstall", () => { }) => { expect(params.selectedId).toBe("memory-b"); expect(params.selectedKind).toBe("memory"); - expect(params.registry?.plugins).toEqual([ - { id: "legacy-memory-a", kind: "memory" }, - { id: "memory-b", kind: "memory" }, - ]); + expect(params.registry?.plugins).toEqual([{ id: "memory-b", kind: "memory" }]); return { config: { ...params.config, plugins: { ...params.config.plugins, - entries: { - ...params.config.plugins?.entries, - "legacy-memory-a": { enabled: false }, - }, slots: { ...params.config.plugins?.slots, memory: "memory-b", @@ -266,11 +249,69 @@ describe("persistPluginInstall", () => { }, }); + expect(buildPluginDiagnosticsReport).not.toHaveBeenCalled(); + expect(loadPluginManifestRegistry).toHaveBeenCalledWith({ + config: enabledConfig, + includeDisabled: true, + pluginIds: ["memory-b"], + }); + expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true); + expect(next.plugins?.slots?.memory).toBe("memory-b"); + }); + + it("does not load every plugin runtime for non-slot installs without manifest kind", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledConfig = { + plugins: { + entries: { + plain: { enabled: true }, + }, + }, + } as OpenClawConfig; + enablePluginInConfig.mockReturnValue({ config: enabledConfig }); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "plain" }], + diagnostics: [], + }); + buildPluginDiagnosticsReport.mockReturnValue({ + plugins: [{ id: "plain" }], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledConfig, + warnings: [], + changed: false, + }); + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "plain", + install: { + source: "path", + sourcePath: "/tmp/plain", + installPath: "/tmp/plain", + }, + }); + + expect(buildPluginDiagnosticsReport).toHaveBeenCalledTimes(1); expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({ config: enabledConfig, + onlyPluginIds: ["plain"], }); - expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(false); - expect(next.plugins?.slots?.memory).toBe("memory-b"); + expect(loadPluginManifestRegistry).toHaveBeenCalledWith({ + config: enabledConfig, + includeDisabled: true, + pluginIds: ["plain"], + }); + expect(next).toEqual(enabledConfig); }); it("can persist an install record without enabling a plugin that needs config first", async () => {