diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb2632fc49..9bdf183e0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Outbound/delivery-queue: persist the originating outbound `session` context on queued delivery entries and replay it during recovery, so write-ahead-queued sends keep their original outbound media policy context after restart instead of evaluating against a missing session. (#66025) Thanks @eleqtrizit. - Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit. - Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky. +- Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional `memory-wiki` gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky. ## 2026.4.12 diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 157248dc2c2..5cc294058d8 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -114,10 +114,11 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; -import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; +import "./components/dashboard-header.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; +import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; import { agentLogoUrl } from "./views/agents-utils.ts"; import { resolveAgentConfig, @@ -236,32 +237,6 @@ function uniquePreserveOrder(values: string[]): string[] { return output; } -function isPluginExplicitlyEnabled( - configSnapshot: AppViewState["configSnapshot"], - pluginId: string, -): boolean { - const config = configSnapshot?.config; - if (!config || typeof config !== "object" || Array.isArray(config)) { - return true; - } - const plugins = - "plugins" in config && config.plugins && typeof config.plugins === "object" - ? (config.plugins as Record) - : null; - if (plugins?.enabled === false) { - return false; - } - const entries = - plugins && "entries" in plugins && plugins.entries && typeof plugins.entries === "object" - ? (plugins.entries as Record) - : null; - const entry = entries?.[pluginId]; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return true; - } - return (entry as { enabled?: unknown }).enabled !== false; -} - type DismissedUpdateBanner = { latestVersion: string; channel: string | null; @@ -457,12 +432,15 @@ export function renderApp(state: AppViewState) { const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving; const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading; const refreshDreaming = () => { - void Promise.all([ - loadDreamingStatus(state), - loadDreamDiary(state), - loadWikiImportInsights(state), - loadWikiMemoryPalace(state), - ]); + void (async () => { + await loadConfig(state); + await Promise.all([ + loadDreamingStatus(state), + loadDreamDiary(state), + loadWikiImportInsights(state), + loadWikiMemoryPalace(state), + ]); + })(); }; const openWikiPage = async (lookup: string) => { if (!state.client || !state.connected) { @@ -2019,7 +1997,11 @@ export function renderApp(state: AppViewState) { dreamDiaryError: state.dreamDiaryError, dreamDiaryPath: state.dreamDiaryPath, dreamDiaryContent: state.dreamDiaryContent, - memoryWikiEnabled: isPluginExplicitlyEnabled(state.configSnapshot, "memory-wiki"), + memoryWikiEnabled: isPluginEnabledInConfigSnapshot( + state.configSnapshot, + "memory-wiki", + { enabledByDefault: false }, + ), wikiImportInsightsLoading: state.wikiImportInsightsLoading, wikiImportInsightsError: state.wikiImportInsightsError, wikiImportInsights: state.wikiImportInsights, @@ -2028,8 +2010,18 @@ export function renderApp(state: AppViewState) { wikiMemoryPalace: state.wikiMemoryPalace, onRefresh: refreshDreaming, onRefreshDiary: () => loadDreamDiary(state), - onRefreshImports: () => loadWikiImportInsights(state), - onRefreshMemoryPalace: () => loadWikiMemoryPalace(state), + onRefreshImports: () => { + void (async () => { + await loadConfig(state); + await loadWikiImportInsights(state); + })(); + }, + onRefreshMemoryPalace: () => { + void (async () => { + await loadConfig(state); + await loadWikiMemoryPalace(state); + })(); + }, onOpenConfig: () => openConfigFile(state), onOpenWikiPage: (lookup: string) => openWikiPage(lookup), onBackfillDiary: () => backfillDreamDiary(state), diff --git a/ui/src/ui/controllers/dreaming.test.ts b/ui/src/ui/controllers/dreaming.test.ts index e7b11f2ff3e..4fa731a68c1 100644 --- a/ui/src/ui/controllers/dreaming.test.ts +++ b/ui/src/ui/controllers/dreaming.test.ts @@ -227,6 +227,18 @@ describe("dreaming controller", () => { it("loads and normalizes wiki import insights", async () => { const { state, request } = createState(); + state.configSnapshot = { + hash: "hash-1", + config: { + plugins: { + entries: { + "memory-wiki": { + enabled: true, + }, + }, + }, + }, + }; request.mockResolvedValue({ sourceType: "chatgpt", totalItems: 2, @@ -285,8 +297,44 @@ describe("dreaming controller", () => { expect(state.wikiImportInsightsLoading).toBe(false); }); + it("skips wiki import insights when memory-wiki is not enabled", async () => { + const { state, request } = createState(); + state.configSnapshot = { + hash: "hash-1", + config: { + plugins: {}, + }, + }; + state.wikiImportInsights = { + sourceType: "chatgpt", + totalItems: 1, + totalClusters: 1, + clusters: [], + }; + state.wikiImportInsightsError = "unknown method: wiki.importInsights"; + + await loadWikiImportInsights(state); + + expect(request).not.toHaveBeenCalled(); + expect(state.wikiImportInsights).toBeNull(); + expect(state.wikiImportInsightsError).toBeNull(); + expect(state.wikiImportInsightsLoading).toBe(false); + }); + it("loads and normalizes the wiki memory palace", async () => { const { state, request } = createState(); + state.configSnapshot = { + hash: "hash-1", + config: { + plugins: { + entries: { + "memory-wiki": { + enabled: true, + }, + }, + }, + }, + }; request.mockResolvedValue({ totalItems: 2, totalClaims: 3, @@ -343,6 +391,31 @@ describe("dreaming controller", () => { expect(state.wikiMemoryPalaceLoading).toBe(false); }); + it("skips wiki memory palace when memory-wiki is not enabled", async () => { + const { state, request } = createState(); + state.configSnapshot = { + hash: "hash-1", + config: { + plugins: {}, + }, + }; + state.wikiMemoryPalace = { + totalItems: 1, + totalClaims: 1, + totalQuestions: 0, + totalContradictions: 0, + clusters: [], + }; + state.wikiMemoryPalaceError = "unknown method: wiki.palace"; + + await loadWikiMemoryPalace(state); + + expect(request).not.toHaveBeenCalled(); + expect(state.wikiMemoryPalace).toBeNull(); + expect(state.wikiMemoryPalaceError).toBeNull(); + expect(state.wikiMemoryPalaceLoading).toBe(false); + }); + it("patches config to update global dreaming enablement", async () => { const { state, request } = createState(); state.configSnapshot = { diff --git a/ui/src/ui/controllers/dreaming.ts b/ui/src/ui/controllers/dreaming.ts index 4ad3c0a15a4..a20098ea9e8 100644 --- a/ui/src/ui/controllers/dreaming.ts +++ b/ui/src/ui/controllers/dreaming.ts @@ -1,9 +1,11 @@ import type { GatewayBrowserClient } from "../gateway.ts"; +import { isPluginEnabledInConfigSnapshot } from "../plugin-activation.ts"; import type { ConfigSnapshot } from "../types.ts"; export type DreamingPhaseId = "light" | "deep" | "rem"; const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md"; const DEFAULT_DREAMING_PLUGIN_ID = "memory-core"; +const MEMORY_WIKI_PLUGIN_ID = "memory-wiki"; type DreamingPhaseStatusBase = { enabled: boolean; @@ -228,6 +230,12 @@ function confirmDreamingAction(message: string): boolean { return globalThis.confirm(message); } +function isMemoryWikiEnabled(state: DreamingState): boolean { + return isPluginEnabledInConfigSnapshot(state.configSnapshot, MEMORY_WIKI_PLUGIN_ID, { + enabledByDefault: false, + }); +} + function buildDreamDiaryActionSuccessMessage( method: | "doctor.memory.backfillDreamDiary" @@ -740,6 +748,11 @@ export async function loadWikiImportInsights(state: DreamingState): Promise if (!state.client || !state.connected || state.wikiMemoryPalaceLoading) { return; } + if (!isMemoryWikiEnabled(state)) { + state.wikiMemoryPalace = null; + state.wikiMemoryPalaceError = null; + return; + } state.wikiMemoryPalaceLoading = true; state.wikiMemoryPalaceError = null; try { diff --git a/ui/src/ui/plugin-activation.test.ts b/ui/src/ui/plugin-activation.test.ts new file mode 100644 index 00000000000..23b40082144 --- /dev/null +++ b/ui/src/ui/plugin-activation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; + +describe("isPluginEnabledInConfigSnapshot", () => { + it("stays permissive when config has not loaded yet", () => { + expect( + isPluginEnabledInConfigSnapshot({ hash: "hash-1" }, "memory-wiki", { + enabledByDefault: false, + }), + ).toBe(true); + }); + + it("treats bundled default-off plugins as disabled when config is present but silent", () => { + expect( + isPluginEnabledInConfigSnapshot( + { + hash: "hash-1", + config: { + plugins: {}, + }, + }, + "memory-wiki", + { + enabledByDefault: false, + }, + ), + ).toBe(false); + }); + + it("returns true when the plugin is explicitly enabled", () => { + expect( + isPluginEnabledInConfigSnapshot( + { + hash: "hash-1", + config: { + plugins: { + entries: { + "memory-wiki": { + enabled: true, + }, + }, + }, + }, + }, + "memory-wiki", + { enabledByDefault: false }, + ), + ).toBe(true); + }); + + it("returns false when plugins.allow excludes the plugin", () => { + expect( + isPluginEnabledInConfigSnapshot( + { + hash: "hash-1", + config: { + plugins: { + allow: ["memory-core"], + entries: { + "memory-wiki": { + enabled: true, + }, + }, + }, + }, + }, + "memory-wiki", + { enabledByDefault: false }, + ), + ).toBe(false); + }); + + it("keeps default-on plugins enabled when config is silent", () => { + expect( + isPluginEnabledInConfigSnapshot({ hash: "hash-1" }, "browser", { + enabledByDefault: true, + }), + ).toBe(true); + }); +}); diff --git a/ui/src/ui/plugin-activation.ts b/ui/src/ui/plugin-activation.ts new file mode 100644 index 00000000000..99803ce94e6 --- /dev/null +++ b/ui/src/ui/plugin-activation.ts @@ -0,0 +1,53 @@ +import type { ConfigSnapshot } from "./types.ts"; + +type PluginActivationOptions = { + enabledByDefault?: boolean; +}; + +export function isPluginEnabledInConfigSnapshot( + configSnapshot: ConfigSnapshot | null | undefined, + pluginId: string, + options?: PluginActivationOptions, +): boolean { + const enabledByDefault = options?.enabledByDefault ?? true; + const config = configSnapshot?.config; + if (!config || typeof config !== "object" || Array.isArray(config)) { + return true; + } + + const plugins = + "plugins" in config && config.plugins && typeof config.plugins === "object" + ? (config.plugins as Record) + : null; + if (plugins?.enabled === false) { + return false; + } + + const deny = + Array.isArray(plugins?.deny) && plugins.deny.every((entry) => typeof entry === "string") + ? plugins.deny + : []; + if (deny.includes(pluginId)) { + return false; + } + + const allow = + Array.isArray(plugins?.allow) && plugins.allow.every((entry) => typeof entry === "string") + ? plugins.allow + : []; + if (allow.length > 0 && !allow.includes(pluginId)) { + return false; + } + + const entries = + plugins && "entries" in plugins && plugins.entries && typeof plugins.entries === "object" + ? (plugins.entries as Record) + : null; + const entry = entries?.[pluginId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return enabledByDefault; + } + + const enabled = (entry as { enabled?: unknown }).enabled; + return typeof enabled === "boolean" ? enabled : enabledByDefault; +}