From c91a37aeed6528d62060e5dcc51d480c25866e3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Jul 2026 21:05:03 -0400 Subject: [PATCH] feat(ui): add a Memory settings page (#114037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add a Memory settings page with Dreaming as a tab Memory config was scattered across five surfaces: the memory.* schema section lived on AI & Agents with 43 of 51 keys behind the Advanced tier, the memory slot owner was only visible on Plugins, dreaming's knobs were JSON-only, its status UI sat under Agents, and Memory Import was a separate route. /settings/memory now owns that surface, following the MCP page shape (curated rows above an embedded schema editor): - Overview: the exclusive memory slot rendered as a segmented control over installed memory-kind plugins, memory.backend promoted out of Advanced with the qmd sub-config revealed only when qmd is selected, additive add-on rows, and a Memory Import link. - Search: the memory.search surface via the embedded editor. - Dreaming: the global frequency/model/timezone/storage/phase knobs, which previously required hand-editing openclaw.json, plus an agent picker feeding the existing dream scene/diary/advanced panel for the agent-scoped reads. Engine selection calls plugins.setEnabled so the gateway's exclusive slot policy stays the single owner instead of being duplicated in the UI. * fix(ui): redirect stale ai-agents memory deep links to the memory page * fix(ui): report memory runtime defaults on the Memory page The Dreaming tab rendered its own defaults instead of the ones resolveMemoryDreamingConfig applies, so a config carrying only dreaming.enabled showed all three phases off while they were running, and an unset storage mode read as inline instead of separate. Toggle specs now carry the runtime fallback and the storage default is stated once, both pointing at src/memory-host-sdk/dreaming.ts. Three more surfaces asserted things the runtime does not do: - plugins.slots.memory "none" is the explicit-off sentinel, not an engine id, so the segmented control selected nothing. The slot now resolves to a closed auto/off/pinned selection with its own hint. - memory.backend is resolved by the memory runtime the slot owner registers, which only memory-core ships, so the row is hidden for any other engine instead of saving a value nothing reads. - The Dreaming tab wrote config.dreaming for whichever plugin owns the slot even when that plugin's schema cannot hold it. It now reuses the enablement flow's schema check (resolveDreamingConfigPathSupport, shared with updateDreamingEnabled) and renders an unsupported state instead. Also key the plugin-catalog sync on the connected phase: the connecting -> connected transition keeps the same client object, so a page mounted during the handshake never loaded the catalog and never showed the engine picker. The tab keeps the autosave status line and restart banner the embedded editor renders on the other tabs; these knobs autosave, but nothing reported it. The pure view moved to memory-dreaming.ts with the element in memory-dreaming-page.ts, matching memory.ts/memory-page.ts. * fix(ui): resolve the memory slot through the canonical policy The Memory page re-derived plugins.slots.memory instead of using the rule the runtime applies, which broke both directions of the engine control: - An unset slot was reported as "the first enabled memory-kind plugin in the catalog". The runtime resolves it to the slot's default owner (DEFAULT_SLOT_BY_KEY.memory), so the page could show one engine as active while another was loaded, reveal or hide the backend row for the wrong plugin, and target the wrong plugin when switching memory off. - Off called plugins.setEnabled(false), which writes enablement only. The slot stayed pinned, so the choice did not survive a refresh and re-enabling that plugin from the Plugins page silently switched memory back on. resolveSlotSelection now lives next to defaultSlotIdForKey in src/plugins/slots.ts and owns the rule once; config normalization consumes it and the page imports it instead of restating it. Off writes the explicit "none" sentinel through the config form, so it round-trips; picking an engine still goes through plugins.setEnabled, which is where the exclusive slot policy lives. The dreaming controller's own copy of the rule is gone too. Four smaller fixes on the same surface: - A failed engine change is reported next to the control instead of being swallowed, so the selector no longer just snaps back. - Dreaming's numeric inputs carry the memory-core manifest's integer/min/max bounds and refuse out-of-range edits at the field, rather than patching a value autosave then fails to write. - Settings search destinations carry the Memory tab that renders the matched child, so a memory.search hit no longer lands on Overview, whose narrowed editor omits it. - The Dreaming tab caches only a definitive schema-capability answer. An offline or failed lookup now reports "unknown" and is retried on reconnect instead of permanently suppressing the recheck. * fix(ui): model unknown memory state instead of collapsing it The Memory page reported unknowns as decided values. An empty catalog meant loading, disconnected, or a failed plugins.list, yet add-on rows rendered "Disabled"; catalog completions were keyed on client identity, which survives a phase flip, so a stale load could repopulate a disconnected page or overwrite a newer read; and `?tab=` was adopted once per distinct value, so a repeat navigation to a tab the user had left was ignored. Replace the ad-hoc nullable fields with closed shapes. MemoryCatalog is a loading/unavailable/ready union, so absence of an entry only decides anything inside `ready`, and MemoryAddonRow carries a four-state enablement the view renders without ever inventing an "off". CatalogConnection is one object per (client, connected) transition and doubles as the request generation an in-flight load carries, so obsolete completions are dropped by identity. The tab is no longer page state at all: the URL owns it, tab clicks navigate, and every arrival is honored. Settings search now resolves the engine/backend through the same resolveMemoryBackend the page uses and matches only the `memory.*` children the page can surface, so a `memory.qmd` hit under the built-in backend no longer routes to an Overview whose editor omits it. * fix(ui): surface a disabled memory owner and anchor curated backend search The slot and plugin enablement are independent config surfaces, so `plugins.slots.memory` can name a plugin the catalog reports as disabled. The engine control showed that plugin as selected, and because re-picking an already-selected radio fires no change event, there was no way back on. Add an explicit enable row for that state and let the same-id write through when the owner is not running; picking Off stays a no-op. `memory.backend` is curated out of the schema editor, so the generic `#config-section-memory` anchor scrolled past it. Fold the memory tab and hash choice into one `memoryDestination` owner that routes a curated-only match to the new anchor above the editor. * fix(ui): scope the dreaming capability probe to its connection The probe was deduplicated by plugin id alone, which cannot tell a current answer from a stale one. A disconnect and reconnect on the same slot owner left the token armed, so the reconnect read as "already in flight" and swallowed the retry that an `unknown` answer requires — leaving an unsupported engine's knobs editable until some unrelated config notification arrived. An A -> B -> A switch had the mirror problem: the old A response was accepted for the new A probe. Make the in-flight probe an object whose identity is the generation, drop it whenever the owner or the connection changes, and accept only the completion that still owns the slot. Same shape as the catalog guard on the Memory page. * fix(ui): satisfy the lint and dead-export gates on the memory page Exhaustive switches need a terminal `default:` to satisfy typescript/consistent-return, matching the existing view-status.ts shape. Seven symbols were exported with no production consumer outside their own module, which the hard-zero Knip production scan rejects. Tests alone do not make internals contracts, so drop the exports and reach the behavior through each module's public surface instead: the view props type comes from `Parameters`, the tab panel is found by its ARIA role, and the dreaming number/storage helpers are proven through `renderDreamingSettings`. Folding those helper unit tests into the render path also corrected one of them: a `type="number"` input coerces unparseable text to empty, so the "reject garbage" case was unreachable through the real control. Replaced with the inclusive-bound and clear-the-field cases, which are reachable. * refactor(ui): keep the memory schema facts out of the startup bundle Settings pages are already lazy — the config route is `import("./config-page.ts")` — but settings search runs from app-host at startup, and it needed the same answers about which `memory.*` children are reachable and where a match lives. Importing those from the view module dragged lit, hub-tabs, and settings-ui into the startup chunk with it, blowing the Control UI startup budget. Move the rendering-free facts (slot/backend resolution, tab and curated key lists, schema narrowing, the anchor id) into memory-schema.ts, which imports only record-coerce and the shared slot policy. The view keeps the templates and now consumes the same module, so there is still one owner per fact. * chore(ui): record the memory settings surface in the startup budget baseline Routing settings search through memory-schema.ts instead of the view module recovered 10,872 B of the startup chunk (334,992 -> 324,120 B), which is back under the 324,608 B ceiling. The remaining 2,795 B over the old baseline is the honest cost of the new surface: its i18n strings, plus the slot/backend facts the startup search index has to read. Measured by hosted CI (run 30189972795); this worktree cannot build locally because pnpm wants to purge a node_modules shared with other running agents. --- .../control-ui-startup-budget-baseline.json | 4 +- src/plugins/config-normalization-shared.ts | 21 +- src/plugins/slots.ts | 34 ++ ui/src/app-navigation.test.ts | 4 + ui/src/app-navigation.ts | 4 +- ui/src/app-route-paths.ts | 1 + ui/src/i18n/locales/en.ts | 149 +++++++ ui/src/pages/agents/agents-page.ts | 1 + ui/src/pages/agents/memory/dreaming.ts | 64 +-- ui/src/pages/agents/view.ts | 6 + ui/src/pages/config/config-page.ts | 29 ++ ui/src/pages/config/config-sections.ts | 17 +- .../pages/config/memory-dreaming-page.test.ts | 91 ++++ ui/src/pages/config/memory-dreaming-page.ts | 229 ++++++++++ ui/src/pages/config/memory-dreaming.test.ts | 145 +++++++ ui/src/pages/config/memory-dreaming.ts | 390 ++++++++++++++++++ ui/src/pages/config/memory-page.test.ts | 350 ++++++++++++++++ ui/src/pages/config/memory-page.ts | 295 +++++++++++++ ui/src/pages/config/memory-schema.ts | 155 +++++++ ui/src/pages/config/memory.test.ts | 221 ++++++++++ ui/src/pages/config/memory.ts | 276 +++++++++++++ ui/src/pages/config/route-data.test.ts | 12 + ui/src/pages/config/route-data.ts | 3 + ui/src/pages/config/route.ts | 1 + ui/src/pages/config/settings-search.test.ts | 128 ++++++ ui/src/pages/config/settings-search.ts | 107 ++++- ui/src/styles/config.css | 18 + 27 files changed, 2698 insertions(+), 57 deletions(-) create mode 100644 ui/src/pages/config/memory-dreaming-page.test.ts create mode 100644 ui/src/pages/config/memory-dreaming-page.ts create mode 100644 ui/src/pages/config/memory-dreaming.test.ts create mode 100644 ui/src/pages/config/memory-dreaming.ts create mode 100644 ui/src/pages/config/memory-page.test.ts create mode 100644 ui/src/pages/config/memory-page.ts create mode 100644 ui/src/pages/config/memory-schema.ts create mode 100644 ui/src/pages/config/memory.test.ts create mode 100644 ui/src/pages/config/memory.ts diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index b79736f28e4e..85a3ad244f77 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 321792, - "reason": "multi-gateway footer subtitle plus accumulated main drift since last record; owner-approved landing (PR #114059); recorded on Linux (CI truth)", + "startupJsGzipBytes": 324587, + "reason": "multi-gateway footer subtitle plus accumulated main drift (PR #114059), then the Memory settings surface: its i18n strings plus the rendering-free memory-schema facts settings search reads at startup", "updatedAt": "2026-07-26" } diff --git a/src/plugins/config-normalization-shared.ts b/src/plugins/config-normalization-shared.ts index 8d0db4254157..43869882d39e 100644 --- a/src/plugins/config-normalization-shared.ts +++ b/src/plugins/config-normalization-shared.ts @@ -1,12 +1,8 @@ // Shares plugin config normalization helpers across control-plane paths. -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "@openclaw/normalization-core/string-coerce"; import { normalizeArrayBackedTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { normalizeChatChannelId } from "../channels/ids.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { defaultSlotIdForKey } from "./slots.js"; +import { normalizeSlotValue, resolveSlotSelection } from "./slots.js"; /** Canonical plugin config shape consumed by runtime policy and loaders. */ export type NormalizedPluginsConfig = { @@ -59,17 +55,6 @@ function normalizeList(value: unknown, normalizePluginId: NormalizePluginId): st .filter(Boolean); } -function normalizeSlotValue(value: unknown): string | null | undefined { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return undefined; - } - if (normalizeOptionalLowercaseString(trimmed) === "none") { - return null; - } - return trimmed; -} - function normalizeHookTimeoutMs(value: unknown): number | undefined { if ( typeof value !== "number" || @@ -229,14 +214,14 @@ export function normalizePluginsConfigWithResolver( config?: OpenClawConfig["plugins"], normalizePluginId: NormalizePluginId = identityNormalizePluginId, ): NormalizedPluginsConfig { - const memorySlot = normalizeSlotValue(config?.slots?.memory); + const memorySlot = resolveSlotSelection("memory", config?.slots?.memory); return { enabled: config?.enabled !== false, allow: normalizeList(config?.allow, normalizePluginId), deny: normalizeList(config?.deny, normalizePluginId), loadPaths: normalizeList(config?.load?.paths, identityNormalizePluginId), slots: { - memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot, + memory: memorySlot.kind === "off" ? null : memorySlot.pluginId, contextEngine: normalizeSlotValue(config?.slots?.contextEngine), }, entries: normalizePluginEntries(config?.entries, normalizePluginId), diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index 98200e32e0bb..7a8e88e7e7e7 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -1,4 +1,8 @@ /** Applies mutually exclusive plugin slot selection for memory and context-engine plugins. */ +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.js"; import type { PluginSlotsConfig } from "../config/types.plugins.js"; import type { PluginKind } from "./plugin-kind.types.js"; @@ -60,6 +64,36 @@ export function defaultSlotIdForKey(slotKey: PluginSlotKey): string { return DEFAULT_SLOT_BY_KEY[slotKey]; } +/** Raw `plugins.slots[key]`: `none` turns the slot off, blank leaves it unset. */ +export function normalizeSlotValue(value: unknown): string | null | undefined { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return undefined; + } + if (normalizeOptionalLowercaseString(trimmed) === "none") { + return null; + } + return trimmed; +} + +/** + * How a configured slot reads. The single owner of the rule: an unset slot is + * the implicit default owner, never "whichever plugin happens to be enabled". + * Config normalization and the Control UI both resolve slots through this. + */ +type SlotSelection = + | { kind: "default"; pluginId: string } + | { kind: "off" } + | { kind: "pinned"; pluginId: string }; + +export function resolveSlotSelection(slotKey: PluginSlotKey, value: unknown): SlotSelection { + const normalized = normalizeSlotValue(value); + if (normalized === undefined) { + return { kind: "default", pluginId: defaultSlotIdForKey(slotKey) }; + } + return normalized === null ? { kind: "off" } : { kind: "pinned", pluginId: normalized }; +} + /** Resets every slot currently owned by a plugin to that slot's implicit default. */ export function resetPluginSlotsToDefaults( slots: PluginSlotsConfig | undefined, diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 3f093fa2cd93..c6959a642efd 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -205,6 +205,7 @@ describe("navigationIconForRoute", () => { appearance: "palette", automation: "terminal", mcp: "wrench", + memory: "book", infrastructure: "globe", labs: "flaskConical", about: "fileText", @@ -313,6 +314,7 @@ describe("titleForRoute", () => { appearance: "Appearance", automation: "Automation", mcp: "MCP", + memory: "Memory", infrastructure: "Infrastructure", labs: "Labs", about: "About", @@ -358,6 +360,7 @@ describe("subtitleForRoute", () => { appearance: "Theme, UI, and setup wizard settings.", automation: "Commands, hooks, cron, and plugins.", mcp: "MCP servers, auth, tools, and diagnostics.", + memory: "Memory engine, backend, search, and dreaming.", infrastructure: "Gateway, web, browser, and media settings.", labs: "Experimental agent and tool capabilities.", about: "Control UI and connected Gateway build identity.", @@ -749,6 +752,7 @@ describe("SIDEBAR_NAV_ROUTES", () => { "labs", "model-providers", "mcp", + "memory", "automation", "security", "approvals", diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index cdc9019f1eea..0332636f2e46 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -183,7 +183,7 @@ export const SETTINGS_NAVIGATION_GROUPS = [ }, { labelKey: "nav.settingsGroupAgents", - routes: ["agents", "ai-agents", "labs", "model-providers", "mcp", "automation"], + routes: ["agents", "ai-agents", "labs", "model-providers", "mcp", "memory", "automation"], }, { labelKey: "nav.settingsGroupSecurity", @@ -231,6 +231,7 @@ const NAVIGATION_ICONS: NavigationItem = { appearance: "palette", automation: "terminal", mcp: "wrench", + memory: "book", infrastructure: "globe", labs: "flaskConical", about: "fileText", @@ -335,6 +336,7 @@ const NAVIGATION_COPY: Record this.saveIdentityDraft(), onChannelsRefresh: () => void this.context.channels.refresh(false), onOpenMemoryImport: () => this.context.navigate("memory-import"), + onOpenMemorySettings: () => this.context.navigate("memory"), onCronRefresh: () => void this.refreshCron(), onCronRunNow: (jobId) => this.runCronJobNow(jobId), onSkillsFilterChange: (next) => (this.skillsFilter = next), diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index 39290f2635f4..0f02aeca95b5 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -1,4 +1,5 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { defaultSlotIdForKey, resolveSlotSelection } from "../../../../../src/plugins/slots.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts"; import type { ConfigSnapshot } from "../../../api/types.ts"; import { copyToClipboard } from "../../../lib/clipboard.ts"; @@ -7,7 +8,6 @@ import { isGatewayMethodAdvertised } from "../../../lib/gateway-methods.ts"; import { isPluginEnabledInConfigSnapshot } from "../../../lib/plugin-activation.ts"; const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md"; -const DEFAULT_DREAMING_PLUGIN_ID = "memory-core"; const MEMORY_WIKI_PLUGIN_ID = "memory-wiki"; type DreamingPhaseStatusBase = { @@ -432,13 +432,11 @@ function normalizePhaseStatusBase(record: Record | null): Dream } function resolveDreamingPluginId(configValue: Record | null): string { - const plugins = asRecord(configValue?.plugins); - const slots = asRecord(plugins?.slots); - const configuredSlot = normalizeTrimmedString(slots?.memory); - if (configuredSlot && configuredSlot.toLowerCase() !== "none") { - return configuredSlot; - } - return DEFAULT_DREAMING_PLUGIN_ID; + const slots = asRecord(asRecord(configValue?.plugins)?.slots); + const selection = resolveSlotSelection("memory", slots?.memory); + // Switching the slot off does not move where dreaming config lives: it stays + // under the default owner so the knobs remain readable and editable. + return selection.kind === "off" ? defaultSlotIdForKey("memory") : selection.pluginId; } export function resolveConfiguredDreaming(configValue: Record | null): { @@ -1208,29 +1206,47 @@ function lookupDisallowsUnknownProperties(value: unknown): boolean { return schema?.additionalProperties === false; } +export type DreamingConfigPathSupport = "supported" | "unsupported" | "unknown"; + +/** + * Whether the slot-owning memory plugin's config schema can hold `dreaming`. + * Only a closed schema without the child proves it cannot. An unreachable + * gateway or a failed lookup answers "unknown", which callers treat as + * optimistic but must not cache: the gateway still has the final say, and a + * cached guess would survive the reconnect that could settle it. + * Shared by the enablement toggle and the Memory page's Dreaming tab. + */ +export async function resolveDreamingConfigPathSupport( + config: Pick, + pluginId: string, +): Promise { + if (!config.state.client || !config.state.connected) { + return "unknown"; + } + try { + const lookup = await config.lookupSchemaPath(`plugins.entries.${pluginId}.config`); + if (lookupIncludesDreamingProperty(lookup)) { + return "supported"; + } + return lookupDisallowsUnknownProperties(lookup) ? "unsupported" : "supported"; + } catch { + return "unknown"; + } +} + async function ensureDreamingPathSupported( state: DreamingState, config: DreamingConfigCapability, pluginId: string, ): Promise { - if (!config.state.client || !config.state.connected) { + // "unknown" stays optimistic: the gateway rejects the write if it is wrong. + if ((await resolveDreamingConfigPathSupport(config, pluginId)) !== "unsupported") { return true; } - try { - const lookup = await config.lookupSchemaPath(`plugins.entries.${pluginId}.config`); - if (lookupIncludesDreamingProperty(lookup)) { - return true; - } - if (lookupDisallowsUnknownProperties(lookup)) { - const message = `Selected memory plugin "${pluginId}" does not support dreaming settings.`; - state.dreamingStatusError = message; - state.lastError = message; - return false; - } - } catch { - return true; - } - return true; + const message = `Selected memory plugin "${pluginId}" does not support dreaming settings.`; + state.dreamingStatusError = message; + state.lastError = message; + return false; } export async function updateDreamingEnabled( diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index 2b189c595df0..86fb08816678 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -132,6 +132,7 @@ type AgentsProps = { onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onChannelsRefresh: () => void; onOpenMemoryImport?: () => void; + onOpenMemorySettings?: () => void; onCronRefresh: () => void; onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; @@ -374,6 +375,11 @@ export function renderAgents(props: AgentsProps) { ${props.activePanel === "memory" ? html`
+ ${renderSettingsNavRow({ + title: t("tabs.memory"), + description: t("subtitles.memory"), + onClick: () => props.onOpenMemorySettings?.(), + })} ${renderSettingsNavRow({ title: t("tabs.memoryImport"), description: t("subtitles.memoryImport"), diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index c819a5dfc105..ce506ea3a5c7 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -48,6 +48,8 @@ import { type ConfigPageId, } from "./config-sections.ts"; import { renderMcp } from "./mcp.ts"; +import { renderMemoryPage } from "./memory-page.ts"; +import { narrowMemorySchema, normalizeMemoryTab } from "./memory-schema.ts"; import { renderQuickSettings } from "./quick.ts"; import { configTargetIdFromHash, type ConfigRouteData } from "./route-data.ts"; import { renderSecurity, type SecurityOverview } from "./security.ts"; @@ -84,6 +86,7 @@ type ConfigPageSetting = const MOVED_SECTION_ROUTES: Record = { "communications:__notifications__": { routeId: "notifications", keepSection: false }, "automation:approvals": { routeId: "security", keepSection: true }, + "ai-agents:memory": { routeId: "memory", keepSection: true }, }; const SYSTEM_INFO_POLL_INTERVAL_MS = 10_000; @@ -114,6 +117,8 @@ function defaultConfigSelection(pageId: ConfigPageId): ConfigSelection { return { activeSection: "commands", activeSubsection: null }; case "mcp": return { activeSection: "mcp", activeSubsection: null }; + case "memory": + return { activeSection: "memory", activeSubsection: null }; case "infrastructure": return { activeSection: "gateway", activeSubsection: null }; case "ai-agents": @@ -248,6 +253,7 @@ export class ConfigPage extends OpenClawLightDomElement { security: "form", automation: "form", mcp: "form", + memory: "form", infrastructure: "form", "ai-agents": "form", advanced: "form", @@ -260,6 +266,7 @@ export class ConfigPage extends OpenClawLightDomElement { security: defaultConfigSelection("security"), automation: defaultConfigSelection("automation"), mcp: defaultConfigSelection("mcp"), + memory: defaultConfigSelection("memory"), infrastructure: defaultConfigSelection("infrastructure"), "ai-agents": defaultConfigSelection("ai-agents"), advanced: defaultConfigSelection("advanced"), @@ -1029,6 +1036,28 @@ export class ConfigPage extends OpenClawLightDomElement { }), }); } + if (this.pageId === "memory") { + return renderMemoryPage({ + configObject, + pluginsHref: pathForRoute("plugins", this.context.basePath), + memoryImportHref: pathForRoute("memory-import", this.context.basePath), + tab: normalizeMemoryTab(this.routeData?.tab), + // Memory's engine and backend are product decisions, not power-user + // knobs: this page forces the advanced tier open so they never hide + // behind the global Advanced toggle. + buildEditor: (keys) => + renderConfig({ + ...props, + schema: narrowMemorySchema(props.schema, keys), + activeSection: "memory", + activeSubsection: null, + showModeToggle: false, + embeddedEditor: true, + forceShowAdvanced: true, + navRootLabel: t("tabs.memory"), + }), + }); + } if (this.pageId === "security") { const runtimeState = runtimeConfig.state; const configBusy = diff --git a/ui/src/pages/config/config-sections.ts b/ui/src/pages/config/config-sections.ts index dbae94d7250e..e38ff96abbaa 100644 --- a/ui/src/pages/config/config-sections.ts +++ b/ui/src/pages/config/config-sections.ts @@ -6,6 +6,7 @@ export type ConfigPageId = | "security" | "automation" | "mcp" + | "memory" | "infrastructure" | "ai-agents" | "advanced"; @@ -47,14 +48,12 @@ export const INFRASTRUCTURE_SECTION_KEYS = [ export const MCP_SECTION_KEYS = ["mcp"] as const; -export const AI_AGENTS_SECTION_KEYS = [ - "agents", - "models", - "skills", - "tools", - "memory", - "session", -] as const; +// Curated Memory home: engine/backend/add-on rows plus the Dreaming tab render +// above the memory schema section (memory.ts). Memory left AI & Agents because +// the engine choice and dreaming's global cron are not agent defaults. +export const MEMORY_SECTION_KEYS = ["memory"] as const; + +export const AI_AGENTS_SECTION_KEYS = ["agents", "models", "skills", "tools", "session"] as const; export const SCOPED_CONFIG_SECTION_KEYS = new Set([ ...COMMUNICATION_SECTION_KEYS, @@ -64,6 +63,7 @@ export const SCOPED_CONFIG_SECTION_KEYS = new Set([ ...AUTOMATION_SECTION_KEYS, ...INFRASTRUCTURE_SECTION_KEYS, ...MCP_SECTION_KEYS, + ...MEMORY_SECTION_KEYS, ...AI_AGENTS_SECTION_KEYS, ]); @@ -78,6 +78,7 @@ const CONFIG_SECTION_KEYS_BY_PAGE = { security: SECURITY_SECTION_KEYS, automation: AUTOMATION_SECTION_KEYS, mcp: MCP_SECTION_KEYS, + memory: MEMORY_SECTION_KEYS, infrastructure: INFRASTRUCTURE_SECTION_KEYS, "ai-agents": AI_AGENTS_SECTION_KEYS, advanced: undefined, diff --git a/ui/src/pages/config/memory-dreaming-page.test.ts b/ui/src/pages/config/memory-dreaming-page.test.ts new file mode 100644 index 000000000000..2906f16cbd8a --- /dev/null +++ b/ui/src/pages/config/memory-dreaming-page.test.ts @@ -0,0 +1,91 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { ApplicationContext } from "../../app/context.ts"; +import { waitForFast } from "../../test-helpers/wait-for.ts"; +import "./memory-dreaming-page.ts"; + +type DreamingPageElement = HTMLElement & { updateComplete: Promise }; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** + * Drives the capability probe directly: `lookupSchemaPath` is the only call the + * probe makes, so handing each invocation its own promise lets a test settle an + * older probe after a newer one. + */ +function createPage(params: { + lookupSchemaPath: (call: number) => Promise; + configObject?: Record; +}) { + let lookups = 0; + const lookupSchemaPath = vi.fn(() => params.lookupSchemaPath(lookups++)); + const listeners = new Set<() => void>(); + const runtimeConfig = { + state: { + client: {}, + connected: true, + configSaving: false, + configApplying: false, + configForm: params.configObject ?? {}, + configSnapshot: null, + }, + subscribe: (notify: () => void) => { + listeners.add(notify); + return () => listeners.delete(notify); + }, + lookupSchemaPath, + patchForm: vi.fn(), + removeFormValue: vi.fn(), + }; + const element = document.createElement("openclaw-memory-dreaming") as DreamingPageElement; + (element as unknown as { context: ApplicationContext }).context = { + runtimeConfig, + agents: { + state: { agentsList: [], agentsLoading: false }, + subscribe: () => () => undefined, + ensureList: () => Promise.resolve(), + }, + } as unknown as ApplicationContext; + const setConnected = (connected: boolean) => { + runtimeConfig.state = { ...runtimeConfig.state, connected }; + for (const notify of listeners) { + notify(); + } + }; + return { element, lookupSchemaPath, setConnected }; +} + +describe("MemoryDreamingSettings capability probe", () => { + it("re-probes after a reconnect instead of trusting the in-flight lookup", async () => { + const first = deferred(); + const second = deferred(); + const { element, lookupSchemaPath, setConnected } = createPage({ + lookupSchemaPath: (call) => (call === 0 ? first.promise : second.promise), + }); + document.body.append(element); + try { + await waitForFast(() => expect(lookupSchemaPath).toHaveBeenCalledTimes(1)); + + // The slot owner is unchanged across the drop, so a plugin-id token would + // still read as "already in flight" and swallow this retry. + setConnected(false); + setConnected(true); + await waitForFast(() => expect(lookupSchemaPath).toHaveBeenCalledTimes(2)); + + // The abandoned probe must not decide the answer it was never asked for. + first.resolve({ type: "object", additionalProperties: false, properties: {} }); + await first.promise; + await element.updateComplete; + expect(element.textContent).not.toContain("does not support"); + } finally { + element.remove(); + } + }); +}); diff --git a/ui/src/pages/config/memory-dreaming-page.ts b/ui/src/pages/config/memory-dreaming-page.ts new file mode 100644 index 000000000000..1feaf6378850 --- /dev/null +++ b/ui/src/pages/config/memory-dreaming-page.ts @@ -0,0 +1,229 @@ +// Controller for the Dreaming tab of the Memory settings page. The dreaming +// sweep is one managed cron job over every agent workspace, so its knobs are +// global and belong on a global page; only the diary/short-term reads below are +// agent-scoped, which is what the agent picker drives. +import { consume } from "@lit/context"; +import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; +import { html, nothing, type TemplateResult } from "lit"; +import { state } from "lit/decorators.js"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import "../../components/agent-select-registration.ts"; +import type { AgentSelectOption } from "../../components/agent-select.ts"; +import { renderSettingsRow, renderSettingsSection } from "../../components/settings-ui.ts"; +import { t } from "../../i18n/index.ts"; +import { listSelectableAgents, normalizeAgentLabel } from "../../lib/agents/display.ts"; +import { currentConfigObject } from "../../lib/config/index.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { + resolveConfiguredDreaming, + resolveDreamingConfigPathSupport, + type DreamingConfigPathSupport, +} from "../agents/memory/dreaming.ts"; +import "../agents/memory/memory-panel.ts"; +import { renderDreamingSettings, renderDreamingUnsupported } from "./memory-dreaming.ts"; +import { renderConfigApplyBanner, renderConfigAutoSaveStatus } from "./view.ts"; + +class MemoryDreamingSettings extends OpenClawLightDomElement { + @consume({ context: applicationContext, subscribe: true }) + private context!: ApplicationContext; + + @state() private selectedAgentId: string | null = null; + // "unknown" renders optimistically: the knobs stay editable until a schema + // lookup proves the slot owner cannot store them, so a slow or offline lookup + // never blanks the tab for the plugin that ships dreaming. + @state() private support: DreamingConfigPathSupport = "unknown"; + private supportPluginId: string | null = null; + /** + * The in-flight capability probe, if any. Object identity is the generation: + * a plugin id alone cannot tell a current answer from a stale one, because + * both the slot owner and the connection can change while a lookup is + * outstanding (A -> B -> A, or a disconnect and reconnect on the same owner). + */ + private supportProbe: { pluginId: string } | null = null; + + private readonly subscriptions = new SubscriptionsController(this) + .watch( + () => this.context?.runtimeConfig, + (runtimeConfig, notify) => runtimeConfig.subscribe(notify), + (runtimeConfig) => this.syncSupport(runtimeConfig), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + (agents) => { + if (!agents.state.agentsList && !agents.state.agentsLoading) { + void agents.ensureList().catch(() => undefined); + } + }, + ); + + override disconnectedCallback() { + this.subscriptions.clear(); + this.supportPluginId = null; + this.supportProbe = null; + super.disconnectedCallback(); + } + + private configObject(): Record | null { + return currentConfigObject(this.context.runtimeConfig.state); + } + + /** Slot-resolved owner of dreaming config; never a hardcoded plugin id here. */ + private dreamingPluginId(): string { + return resolveConfiguredDreaming(this.configObject()).pluginId; + } + + private dreamingConfig(): Record | null { + const plugins = asConfigRecord(this.configObject()?.plugins); + const entry = asConfigRecord(asConfigRecord(plugins?.entries)?.[this.dreamingPluginId()]); + return asConfigRecord(asConfigRecord(entry?.config)?.dreaming); + } + + /** + * Reuses the enablement flow's capability check so exactly one rule decides + * whether an alternate memory engine can hold `config.dreaming`. Only a + * definitive answer is kept: a lookup made while the gateway was unreachable + * answers "unknown" and must be retried on reconnect, or the page would stay + * optimistically editable for an engine that cannot store these knobs. + */ + private syncSupport(runtimeConfig: ApplicationContext["runtimeConfig"]) { + const pluginId = resolveConfiguredDreaming(currentConfigObject(runtimeConfig.state)).pluginId; + if (pluginId !== this.supportPluginId) { + this.supportPluginId = pluginId; + this.support = "unknown"; + } + const connected = runtimeConfig.state.connected; + // Abandoning the probe here is what makes the reconnect re-probe: leaving it + // armed would make this call look like "already in flight" and swallow the + // retry that an `unknown` answer requires. + if (this.supportProbe && (this.supportProbe.pluginId !== pluginId || !connected)) { + this.supportProbe = null; + } + if (this.support !== "unknown" || this.supportProbe || !connected) { + return; + } + const probe = { pluginId }; + this.supportProbe = probe; + void resolveDreamingConfigPathSupport(runtimeConfig, pluginId).then((support) => { + if (this.supportProbe !== probe) { + return; + } + this.supportProbe = null; + if (this.isConnected) { + this.support = support; + } + }); + } + + private patch(path: readonly string[], value: unknown) { + const runtimeConfig = this.context.runtimeConfig; + const writePath = [ + "plugins", + "entries", + this.dreamingPluginId(), + "config", + "dreaming", + ...path, + ]; + if (value === undefined) { + runtimeConfig.removeFormValue(writePath); + return; + } + runtimeConfig.patchForm(writePath, value); + } + + private resolveAgentId(): string | null { + const agentsList = this.context.agents.state.agentsList; + const selectable = listSelectableAgents(agentsList?.agents ?? []); + if (this.selectedAgentId && selectable.some((agent) => agent.id === this.selectedAgentId)) { + return this.selectedAgentId; + } + return agentsList?.defaultId ?? selectable[0]?.id ?? null; + } + + /** + * These knobs autosave like the schema editor, so the tab owns the same + * status line and restart banner the embedded editor renders on the other + * tabs; without them a saved edit looks like nothing happened. + */ + private renderWriteStatus() { + const runtimeConfig = this.context.runtimeConfig; + const configState = runtimeConfig.state; + const status = renderConfigAutoSaveStatus({ + status: configState.configAutoSaveStatus, + onRetry: () => void runtimeConfig.save(), + onReload: () => void runtimeConfig.discardDraft(), + }); + return html` + ${status === nothing + ? nothing + : html`
+ ${status} +
`} + ${renderConfigApplyBanner({ + needsApply: configState.configNeedsApply, + applying: configState.configApplying, + busy: + configState.configSaving || + configState.configLoading || + configState.configAutoSaveStatus === "saving", + connected: configState.connected, + onApply: () => void runtimeConfig.apply(), + })} + `; + } + + private renderAgentPicker(agentId: string | null): TemplateResult { + const agents = listSelectableAgents(this.context.agents.state.agentsList?.agents ?? []); + const options: AgentSelectOption[] = agents.map((agent) => ({ + value: agent.id, + label: normalizeAgentLabel(agent), + agent, + })); + return renderSettingsSection( + { + title: t("memoryPage.dreaming.agentScope.title"), + description: t("memoryPage.dreaming.agentScope.description"), + }, + renderSettingsRow({ + title: t("memoryPage.dreaming.agentScope.rowTitle"), + control: html` + { + this.selectedAgentId = value || null; + }} + > + `, + }), + ); + } + + override render() { + const agentId = this.resolveAgentId(); + const pluginId = this.dreamingPluginId(); + return html` +
+ ${this.renderWriteStatus()} +

${t("memoryPage.dreaming.intro", { plugin: pluginId })}

+ ${this.support === "unsupported" + ? renderDreamingUnsupported(pluginId) + : renderDreamingSettings({ + dreaming: this.dreamingConfig(), + onPatch: (path, value) => this.patch(path, value), + })} + ${this.renderAgentPicker(agentId)} +
+ ${agentId + ? html`` + : nothing} + `; + } +} + +if (!customElements.get("openclaw-memory-dreaming")) { + customElements.define("openclaw-memory-dreaming", MemoryDreamingSettings); +} diff --git a/ui/src/pages/config/memory-dreaming.test.ts b/ui/src/pages/config/memory-dreaming.test.ts new file mode 100644 index 000000000000..46b531dc4a50 --- /dev/null +++ b/ui/src/pages/config/memory-dreaming.test.ts @@ -0,0 +1,145 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderDreamingSettings } from "./memory-dreaming.ts"; + +function renderInto( + dreaming: Record | null, + onPatch: (path: readonly string[], value: unknown) => void = vi.fn(), +): HTMLElement { + const container = document.createElement("div"); + render(renderDreamingSettings({ dreaming, onPatch }), container); + return container; +} + +function numberInput(container: HTMLElement, label: string): HTMLInputElement { + const input = [...container.querySelectorAll("input.settings-input")].find( + (candidate) => candidate.getAttribute("aria-label") === label, + ); + if (!input) { + throw new Error(`no input labelled ${label}`); + } + return input; +} + +function editNumber(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("change")); +} + +/** Toggle state keyed by "
/"; `checked` is a property binding. */ +function toggleStates(container: HTMLElement): Record { + const states: Record = {}; + for (const row of container.querySelectorAll(".settings-row--toggle")) { + const title = row.querySelector(".settings-row__title")?.textContent?.trim() ?? ""; + const section = row.closest(".settings-section")?.querySelector(".settings-section__heading"); + const key = `${section?.textContent?.trim() ?? ""}/${title}`; + const toggle = row.querySelector("wa-switch"); + states[key] = toggle?.checked === true; + } + return states; +} + +function selectedSegment(container: HTMLElement): string | null { + return ( + container.querySelector("wa-radio.settings-segmented__btn--active")?.getAttribute("value") ?? + null + ); +} + +describe("renderDreamingSettings", () => { + // resolveMemoryDreamingConfig defaults every phase's `enabled` to true, so a + // config that only turns dreaming on is running all three phases. + it("renders every phase as on when the config only sets dreaming.enabled", () => { + const states = toggleStates(renderInto({ enabled: true })); + + expect(states["Light phase/Enabled"]).toBe(true); + expect(states["Deep phase/Enabled"]).toBe(true); + expect(states["REM phase/Enabled"]).toBe(true); + }); + + it("still renders a phase that config explicitly disables as off", () => { + const states = toggleStates( + renderInto({ enabled: true, phases: { deep: { enabled: false } } }), + ); + + expect(states["Light phase/Enabled"]).toBe(true); + expect(states["Deep phase/Enabled"]).toBe(false); + }); + + it("keeps toggles that default to off unchecked when absent", () => { + const states = toggleStates(renderInto(null)); + + expect(states["Schedule/Verbose logging"]).toBe(false); + expect(states["Storage/Separate reports"]).toBe(false); + }); + + it("renders the runtime storage-mode default when the config omits it", () => { + expect(selectedSegment(renderInto({ enabled: true }))).toBe("separate"); + expect(selectedSegment(renderInto({ storage: { mode: "inline" } }))).toBe("inline"); + expect(selectedSegment(renderInto({ storage: { mode: "both" } }))).toBe("both"); + // An unreadable stored value is not a fourth mode: it reads as the default. + expect(selectedSegment(renderInto({ storage: { mode: "nonsense" } }))).toBe("separate"); + }); +}); + +describe("numeric field bounds", () => { + // extensions/memory-core/openclaw.plugin.json: counts are integers with a + // minimum, similarity/score fields are numbers in 0..1. + it("rejects values the memory-core manifest would refuse instead of patching them", () => { + const onPatch = vi.fn(); + const container = renderInto({ enabled: true }, onPatch); + + editNumber(numberInput(container, "Lookback days"), "-1"); + editNumber(numberInput(container, "Limit"), "2.5"); + editNumber(numberInput(container, "Dedupe similarity"), "1.4"); + editNumber(numberInput(container, "Maximum age (days)"), "0"); + expect(onPatch).not.toHaveBeenCalled(); + + editNumber(numberInput(container, "Lookback days"), "7"); + editNumber(numberInput(container, "Dedupe similarity"), "0.82"); + expect(onPatch).toHaveBeenNthCalledWith(1, ["phases", "light", "lookbackDays"], 7); + expect(onPatch).toHaveBeenNthCalledWith(2, ["phases", "light", "dedupeSimilarity"], 0.82); + }); + + it("restores the stored value so a refused edit does not linger in the field", () => { + const container = renderInto({ phases: { light: { lookbackDays: 7 } } }); + const input = numberInput(container, "Lookback days"); + + editNumber(input, "-3"); + expect(input.value).toBe("7"); + }); + + it("treats the manifest bounds as inclusive", () => { + const onPatch = vi.fn(); + const container = renderInto({ enabled: true }, onPatch); + + editNumber(numberInput(container, "Dedupe similarity"), "1"); + editNumber(numberInput(container, "Dedupe similarity"), "0"); + expect(onPatch).toHaveBeenNthCalledWith(1, ["phases", "light", "dedupeSimilarity"], 1); + expect(onPatch).toHaveBeenNthCalledWith(2, ["phases", "light", "dedupeSimilarity"], 0); + }); + + it("clears the stored value when the field is emptied", () => { + const onPatch = vi.fn(); + const container = renderInto({ phases: { light: { lookbackDays: 7 } } }, onPatch); + + editNumber(numberInput(container, "Lookback days"), ""); + expect(onPatch).toHaveBeenCalledWith(["phases", "light", "lookbackDays"], undefined); + }); + + it("advertises the manifest bounds on the inputs", () => { + const container = renderInto(null); + + const similarity = numberInput(container, "Dedupe similarity"); + expect(similarity.getAttribute("min")).toBe("0"); + expect(similarity.getAttribute("max")).toBe("1"); + expect(similarity.getAttribute("step")).toBe("any"); + + const maxAge = numberInput(container, "Maximum age (days)"); + expect(maxAge.getAttribute("min")).toBe("1"); + expect(maxAge.getAttribute("step")).toBe("1"); + expect(maxAge.getAttribute("max")).toBeNull(); + }); +}); diff --git a/ui/src/pages/config/memory-dreaming.ts b/ui/src/pages/config/memory-dreaming.ts new file mode 100644 index 000000000000..8821e8fe2552 --- /dev/null +++ b/ui/src/pages/config/memory-dreaming.ts @@ -0,0 +1,390 @@ +// Pure view for the Dreaming tab of the Memory settings page: the global +// schedule/storage/phase knobs. The controller (context, config writes, agent +// picker) lives in memory-dreaming-page.ts, mirroring memory.ts/memory-page.ts. +import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; +import { html, nothing, type TemplateResult } from "lit"; +import { + renderSettingsRow, + renderSettingsSection, + renderSettingsSegmented, + renderSettingsToggleRow, +} from "../../components/settings-ui.ts"; +import { t } from "../../i18n/index.ts"; + +/** Manifest bounds for a numeric field; `count` is `{integer, minimum}`, `ratio` is `0..1`. */ +type DreamingNumberBounds = { integer: boolean; min: number; max?: number }; + +const COUNT_FROM_ZERO: DreamingNumberBounds = { integer: true, min: 0 }; +const COUNT_FROM_ONE: DreamingNumberBounds = { integer: true, min: 1 }; +const RATIO: DreamingNumberBounds = { integer: false, min: 0, max: 1 }; + +type DreamingFieldSpec = + | { + kind: "text"; + path: readonly string[]; + labelKey: string; + helpKey: string; + placeholderKey?: string; + } + | { + kind: "number"; + path: readonly string[]; + labelKey: string; + helpKey: string; + bounds: DreamingNumberBounds; + } + | { + kind: "toggle"; + path: readonly string[]; + labelKey: string; + helpKey: string; + /** Runtime value for an absent key; see resolveMemoryDreamingConfig. */ + fallback: boolean; + }; + +type DreamingFieldGroup = { + titleKey: string; + descriptionKey: string; + fields: readonly DreamingFieldSpec[]; +}; + +// Mirrors the memory-core manifest configSchema/uiHints +// (extensions/memory-core/openclaw.plugin.json). Everything here previously +// required hand-editing openclaw.json. `bounds` restates that manifest's +// integer/minimum/maximum constraints so a rejected value is caught at the input +// instead of after autosave hands it to the gateway. +// +// Toggle `fallback` and the storage-mode default below restate +// resolveMemoryDreamingConfig in src/memory-host-sdk/dreaming.ts: an absent +// key is not "off", so rendering `false` would report the opposite of what the +// sweep actually does. Keep the two in sync. +const DREAMING_SCHEDULE_FIELDS: readonly DreamingFieldSpec[] = [ + { + kind: "text", + path: ["frequency"], + labelKey: "memoryPage.dreaming.frequency.label", + helpKey: "memoryPage.dreaming.frequency.help", + placeholderKey: "memoryPage.dreaming.frequency.placeholder", + }, + { + kind: "text", + path: ["timezone"], + labelKey: "memoryPage.dreaming.timezone.label", + helpKey: "memoryPage.dreaming.timezone.help", + placeholderKey: "memoryPage.dreaming.timezone.placeholder", + }, + { + kind: "text", + path: ["model"], + labelKey: "memoryPage.dreaming.model.label", + helpKey: "memoryPage.dreaming.model.help", + placeholderKey: "memoryPage.dreaming.model.placeholder", + }, + { + kind: "toggle", + path: ["verboseLogging"], + labelKey: "memoryPage.dreaming.verboseLogging.label", + helpKey: "memoryPage.dreaming.verboseLogging.help", + // DEFAULT_MEMORY_DREAMING_VERBOSE_LOGGING + fallback: false, + }, +]; + +const DREAMING_PHASE_GROUPS: readonly DreamingFieldGroup[] = [ + { + titleKey: "memoryPage.dreaming.phases.light.title", + descriptionKey: "memoryPage.dreaming.phases.light.description", + fields: [ + { + kind: "toggle", + path: ["phases", "light", "enabled"], + labelKey: "memoryPage.dreaming.phaseFields.enabled", + helpKey: "memoryPage.dreaming.phaseFields.enabledHelp", + fallback: true, + }, + { + kind: "number", + path: ["phases", "light", "lookbackDays"], + labelKey: "memoryPage.dreaming.phaseFields.lookbackDays", + helpKey: "memoryPage.dreaming.phaseFields.lookbackDaysHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "light", "limit"], + labelKey: "memoryPage.dreaming.phaseFields.limit", + helpKey: "memoryPage.dreaming.phaseFields.limitHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "light", "dedupeSimilarity"], + labelKey: "memoryPage.dreaming.phaseFields.dedupeSimilarity", + helpKey: "memoryPage.dreaming.phaseFields.dedupeSimilarityHelp", + bounds: RATIO, + }, + ], + }, + { + titleKey: "memoryPage.dreaming.phases.deep.title", + descriptionKey: "memoryPage.dreaming.phases.deep.description", + fields: [ + { + kind: "toggle", + path: ["phases", "deep", "enabled"], + labelKey: "memoryPage.dreaming.phaseFields.enabled", + helpKey: "memoryPage.dreaming.phaseFields.enabledHelp", + fallback: true, + }, + { + kind: "number", + path: ["phases", "deep", "limit"], + labelKey: "memoryPage.dreaming.phaseFields.limit", + helpKey: "memoryPage.dreaming.phaseFields.limitHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "deep", "minScore"], + labelKey: "memoryPage.dreaming.phaseFields.minScore", + helpKey: "memoryPage.dreaming.phaseFields.minScoreHelp", + bounds: RATIO, + }, + { + kind: "number", + path: ["phases", "deep", "minRecallCount"], + labelKey: "memoryPage.dreaming.phaseFields.minRecallCount", + helpKey: "memoryPage.dreaming.phaseFields.minRecallCountHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "deep", "minUniqueQueries"], + labelKey: "memoryPage.dreaming.phaseFields.minUniqueQueries", + helpKey: "memoryPage.dreaming.phaseFields.minUniqueQueriesHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "deep", "recencyHalfLifeDays"], + labelKey: "memoryPage.dreaming.phaseFields.recencyHalfLifeDays", + helpKey: "memoryPage.dreaming.phaseFields.recencyHalfLifeDaysHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "deep", "maxAgeDays"], + labelKey: "memoryPage.dreaming.phaseFields.maxAgeDays", + helpKey: "memoryPage.dreaming.phaseFields.maxAgeDaysHelp", + bounds: COUNT_FROM_ONE, + }, + { + kind: "number", + path: ["phases", "deep", "maxPromotedSnippetTokens"], + labelKey: "memoryPage.dreaming.phaseFields.maxPromotedSnippetTokens", + helpKey: "memoryPage.dreaming.phaseFields.maxPromotedSnippetTokensHelp", + bounds: COUNT_FROM_ONE, + }, + ], + }, + { + titleKey: "memoryPage.dreaming.phases.rem.title", + descriptionKey: "memoryPage.dreaming.phases.rem.description", + fields: [ + { + kind: "toggle", + path: ["phases", "rem", "enabled"], + labelKey: "memoryPage.dreaming.phaseFields.enabled", + helpKey: "memoryPage.dreaming.phaseFields.enabledHelp", + fallback: true, + }, + { + kind: "number", + path: ["phases", "rem", "lookbackDays"], + labelKey: "memoryPage.dreaming.phaseFields.lookbackDays", + helpKey: "memoryPage.dreaming.phaseFields.lookbackDaysHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "rem", "limit"], + labelKey: "memoryPage.dreaming.phaseFields.limit", + helpKey: "memoryPage.dreaming.phaseFields.limitHelp", + bounds: COUNT_FROM_ZERO, + }, + { + kind: "number", + path: ["phases", "rem", "minPatternStrength"], + labelKey: "memoryPage.dreaming.phaseFields.minPatternStrength", + helpKey: "memoryPage.dreaming.phaseFields.minPatternStrengthHelp", + bounds: RATIO, + }, + ], + }, +]; + +const STORAGE_MODES = ["inline", "separate", "both"] as const; +type StorageMode = (typeof STORAGE_MODES)[number]; + +// DEFAULT_MEMORY_DREAMING_STORAGE_MODE in src/memory-host-sdk/dreaming.ts. +const DEFAULT_STORAGE_MODE: StorageMode = "separate"; + +type DreamingSettingsProps = { + /** `plugins.entries..config.dreaming`, or null when unset. */ + dreaming: Record | null; + onPatch: (path: readonly string[], value: unknown) => void; +}; + +function readAtPath(root: Record | null, path: readonly string[]): unknown { + let current: Record | null = root; + for (const [index, key] of path.entries()) { + if (!current) { + return undefined; + } + const next = current[key]; + if (index === path.length - 1) { + return next; + } + current = asConfigRecord(next); + } + return undefined; +} + +function normalizeStorageMode(value: unknown): StorageMode { + return STORAGE_MODES.find((mode) => mode === value) ?? DEFAULT_STORAGE_MODE; +} + +/** Parses an edited number against its manifest bounds; null means "do not write". */ +function parseDreamingNumber(raw: string, bounds: DreamingNumberBounds): number | null { + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < bounds.min) { + return null; + } + if (bounds.integer && !Number.isInteger(parsed)) { + return null; + } + return bounds.max !== undefined && parsed > bounds.max ? null : parsed; +} + +function renderField(props: DreamingSettingsProps, spec: DreamingFieldSpec) { + const value = readAtPath(props.dreaming, spec.path); + if (spec.kind === "toggle") { + return renderSettingsToggleRow({ + title: t(spec.labelKey), + description: t(spec.helpKey), + checked: typeof value === "boolean" ? value : spec.fallback, + onChange: (checked) => props.onPatch(spec.path, checked), + }); + } + const text = + spec.kind === "number" + ? typeof value === "number" + ? String(value) + : "" + : typeof value === "string" + ? value + : ""; + const bounds = spec.kind === "number" ? spec.bounds : null; + return renderSettingsRow({ + title: t(spec.labelKey), + description: t(spec.helpKey), + control: html` + { + const input = event.currentTarget as HTMLInputElement; + const next = input.value.trim(); + if (!next) { + props.onPatch(spec.path, undefined); + return; + } + if (bounds) { + const parsed = parseDreamingNumber(next, bounds); + if (parsed === null) { + // Autosave would write this straight into a config the gateway + // rejects, leaving a failed save and no field to correct. + input.value = text; + return; + } + props.onPatch(spec.path, parsed); + return; + } + props.onPatch(spec.path, next); + }} + /> + `, + }); +} + +/** The global dreaming knobs, editable only when the slot owner stores them. */ +export function renderDreamingSettings(props: DreamingSettingsProps): TemplateResult { + const storageMode = normalizeStorageMode(readAtPath(props.dreaming, ["storage", "mode"])); + return html` + ${renderSettingsSection( + { + title: t("memoryPage.dreaming.schedule.title"), + description: t("memoryPage.dreaming.schedule.description"), + }, + DREAMING_SCHEDULE_FIELDS.map((spec) => renderField(props, spec)), + )} + ${renderSettingsSection( + { + title: t("memoryPage.dreaming.storage.title"), + description: t("memoryPage.dreaming.storage.description"), + }, + html` + ${renderSettingsRow({ + title: t("memoryPage.dreaming.storage.modeLabel"), + description: t("memoryPage.dreaming.storage.modeHelp"), + stacked: true, + control: renderSettingsSegmented({ + value: storageMode, + options: STORAGE_MODES.map((mode) => ({ + value: mode, + label: t(`memoryPage.dreaming.storage.modes.${mode}`), + })), + ariaLabel: t("memoryPage.dreaming.storage.modeLabel"), + onChange: (mode) => props.onPatch(["storage", "mode"], mode), + }), + })} + ${renderField(props, { + kind: "toggle", + path: ["storage", "separateReports"], + labelKey: "memoryPage.dreaming.storage.separateReportsLabel", + helpKey: "memoryPage.dreaming.storage.separateReportsHelp", + // DEFAULT_MEMORY_DREAMING_SEPARATE_REPORTS + fallback: false, + })} + `, + )} + ${DREAMING_PHASE_GROUPS.map((group) => + renderSettingsSection( + { title: t(group.titleKey), description: t(group.descriptionKey) }, + group.fields.map((spec) => renderField(props, spec)), + ), + )} + `; +} + +/** + * Shown instead of the knobs when the slot-owning plugin's config schema has no + * `dreaming` child: writing these fields would be rejected by the gateway, so + * the page must not pretend they are editable. + */ +export function renderDreamingUnsupported(pluginId: string): TemplateResult { + return renderSettingsSection( + { title: t("memoryPage.dreaming.unsupported.title") }, + renderSettingsRow({ + title: t("memoryPage.dreaming.unsupported.rowTitle"), + description: t("memoryPage.dreaming.unsupported.description", { plugin: pluginId }), + }), + ); +} diff --git a/ui/src/pages/config/memory-page.test.ts b/ui/src/pages/config/memory-page.test.ts new file mode 100644 index 000000000000..c7fc0963232d --- /dev/null +++ b/ui/src/pages/config/memory-page.test.ts @@ -0,0 +1,350 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { ApplicationContext } from "../../app/context.ts"; +import type { PluginCatalogItem } from "../../lib/plugins/index.ts"; +import { waitForFast } from "../../test-helpers/wait-for.ts"; +import "./memory-page.ts"; + +type MemoryPageElement = HTMLElement & { + configObject: Record; + tab: string | null; + updateComplete: Promise; +}; + +function engine(id: string, enabled: boolean): PluginCatalogItem { + return { + id, + name: id, + installed: true, + enabled, + kind: ["memory"], + } as unknown as PluginCatalogItem; +} + +function addon(id: string, enabled: boolean): PluginCatalogItem { + return { id, name: id, installed: true, enabled } as unknown as PluginCatalogItem; +} + +function createPage(params: { + configObject: Record; + /** Resolves one `plugins.list` call; the default answers every call with `catalog`. */ + listCatalog?: (call: number) => Promise<{ plugins: readonly PluginCatalogItem[] }>; + catalog?: readonly PluginCatalogItem[]; + patchForm?: (path: Array, value: unknown) => void; + setEnabled?: () => Promise; + navigate?: (routeId: string, options?: { search?: string }) => void; +}) { + let listCalls = 0; + const request = vi.fn((method: string) => { + if (method === "plugins.list") { + const call = listCalls++; + return params.listCatalog + ? params.listCatalog(call) + : Promise.resolve({ plugins: params.catalog ?? [] }); + } + return params.setEnabled ? params.setEnabled() : Promise.resolve({}); + }); + const listeners = new Set<() => void>(); + const gateway = { + snapshot: { client: { request }, phase: "connected" }, + subscribe: (notify: () => void) => { + listeners.add(notify); + return () => listeners.delete(notify); + }, + }; + const element = document.createElement("openclaw-memory-settings") as MemoryPageElement; + element.configObject = params.configObject; + (element as unknown as { context: ApplicationContext }).context = { + gateway, + runtimeConfig: { + state: { configSaving: false, configApplying: false }, + patchForm: params.patchForm ?? vi.fn(), + refresh: () => Promise.resolve(), + }, + navigate: params.navigate ?? vi.fn(), + } as unknown as ApplicationContext; + const setPhase = (phase: string) => { + gateway.snapshot = { ...gateway.snapshot, phase }; + for (const notify of listeners) { + notify(); + } + }; + return { element, request, setPhase }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function addonStatus(element: HTMLElement, label: string): string | null { + const row = [...element.querySelectorAll(".settings-row")].find((entry) => + entry.textContent?.includes(label), + ); + return row?.querySelector(".settings-status")?.textContent?.trim() ?? null; +} + +/** Which tab body is actually mounted, rather than what the tab strip claims. */ +function visibleTab(element: HTMLElement): "overview" | "search" | "dreaming" | null { + const panel = element.querySelector('[role="tabpanel"]'); + if (!panel) { + return null; + } + if (panel.querySelector("openclaw-memory-dreaming")) { + return "dreaming"; + } + return panel.querySelector(".settings-page__intro") ? "search" : "overview"; +} + +function selectTab(element: HTMLElement, tab: string) { + element + .querySelector("wa-tab-group") + ?.dispatchEvent(new CustomEvent("wa-tab-show", { detail: { name: tab }, bubbles: true })); +} + +function activeEngine(element: HTMLElement): string | null { + return ( + element.querySelector("wa-radio.settings-segmented__btn--active")?.getAttribute("value") ?? null + ); +} + +function selectEngine(element: HTMLElement, value: string) { + const group = element.querySelector("wa-radio-group") as HTMLElement & { value?: string }; + group.value = value; + group.dispatchEvent(new Event("change")); +} + +describe("MemorySettingsPage engine slot", () => { + it("resolves an unset slot to the slot default even when another engine is enabled", async () => { + // resolveSlotSelection (src/plugins/slots.ts) makes an unset slot memory-core + // regardless of catalog enablement, so the page must not report lancedb. + const { element } = createPage({ + configObject: {}, + catalog: [engine("memory-core", false), engine("memory-lancedb", true)], + }); + document.body.append(element); + try { + await waitForFast(() => expect(activeEngine(element)).toBe("memory-core")); + expect(element.textContent).toContain("falls back to its default owner"); + } finally { + element.remove(); + } + }); + + it("offers an enable action when the slot owner is disabled", async () => { + const setEnabled = vi.fn(() => Promise.resolve({})); + const { element } = createPage({ + configObject: {}, + catalog: [engine("memory-core", false)], + setEnabled, + }); + document.body.append(element); + try { + await waitForFast(() => expect(element.textContent).toContain("This engine is disabled")); + + // The control already shows memory-core selected, so re-picking it fires no + // change event; without this button the owner could never be re-enabled. + const enable = [...element.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Enable", + ); + enable?.click(); + await waitForFast(() => expect(setEnabled).toHaveBeenCalled()); + } finally { + element.remove(); + } + }); + + it("keeps the enable action hidden once the owner is running", async () => { + const { element } = createPage({ + configObject: {}, + catalog: [engine("memory-core", true)], + }); + document.body.append(element); + try { + await waitForFast(() => expect(activeEngine(element)).toBe("memory-core")); + expect(element.textContent).not.toContain("This engine is disabled"); + } finally { + element.remove(); + } + }); + + it("persists Off as the none slot so it survives a config refresh", async () => { + const patchForm = vi.fn(); + const setEnabled = vi.fn(() => Promise.resolve({})); + const { element } = createPage({ + configObject: { plugins: { slots: { memory: "memory-lancedb" } } }, + catalog: [engine("memory-core", false), engine("memory-lancedb", true)], + patchForm, + setEnabled, + }); + document.body.append(element); + try { + await waitForFast(() => expect(activeEngine(element)).toBe("memory-lancedb")); + + selectEngine(element, ""); + // Disabling the plugin would leave the slot pinned; only the explicit + // sentinel makes Off outlive a reload. + expect(patchForm).toHaveBeenCalledWith(["plugins", "slots", "memory"], "none"); + expect(setEnabled).not.toHaveBeenCalled(); + + // Round-trip: the reloaded config carries the write back into the page. + element.configObject = { plugins: { slots: { memory: "none" } } }; + await element.updateComplete; + expect(activeEngine(element)).toBe(""); + expect(element.textContent).toContain("switched off"); + } finally { + element.remove(); + } + }); + + it("reports a rejected engine change instead of silently snapping back", async () => { + const { element } = createPage({ + configObject: {}, + catalog: [engine("memory-core", true), engine("memory-lancedb", false)], + setEnabled: () => Promise.reject(new Error("plugin not installed: memory-lancedb")), + }); + document.body.append(element); + try { + await waitForFast(() => expect(activeEngine(element)).toBe("memory-core")); + + selectEngine(element, "memory-lancedb"); + await waitForFast(() => + expect(element.textContent).toContain("plugin not installed: memory-lancedb"), + ); + expect(element.textContent).toContain("Could not change the memory engine"); + } finally { + element.remove(); + } + }); +}); + +describe("MemorySettingsPage catalog state", () => { + it("does not claim an add-on is disabled before the catalog is read", async () => { + const pending = deferred<{ plugins: readonly PluginCatalogItem[] }>(); + const { element } = createPage({ + configObject: {}, + listCatalog: () => pending.promise, + }); + document.body.append(element); + try { + await element.updateComplete; + // The catalog is still in flight: "Disabled" here would be a definite claim + // about a plugin whose entry was never read. + expect(addonStatus(element, "Active memory")).toBe("Loading…"); + + pending.resolve({ plugins: [addon("active-memory", true)] }); + await waitForFast(() => expect(addonStatus(element, "Active memory")).toBe("Enabled")); + // A read that succeeded but has no entry really does mean not enabled. + expect(addonStatus(element, "Memory wiki")).toBe("Disabled"); + } finally { + element.remove(); + } + }); + + it("reports unknown add-on state instead of disabled once the catalog read fails", async () => { + const { element } = createPage({ + configObject: {}, + listCatalog: () => Promise.reject(new Error("gateway is gone")), + }); + document.body.append(element); + try { + await waitForFast(() => expect(addonStatus(element, "Active memory")).toBe("Unknown")); + expect(element.textContent).not.toContain("Disabled"); + } finally { + element.remove(); + } + }); + + it("drops a catalog completion from a superseded connection", async () => { + const first = deferred<{ plugins: readonly PluginCatalogItem[] }>(); + const second = deferred<{ plugins: readonly PluginCatalogItem[] }>(); + const { element, setPhase } = createPage({ + configObject: {}, + listCatalog: (call) => (call === 0 ? first.promise : second.promise), + }); + document.body.append(element); + try { + await element.updateComplete; + // Same client object survives the drop and the reconnect, so only the + // per-connection request generation can tell the two loads apart. + setPhase("disconnected"); + setPhase("connected"); + await waitForFast(() => expect(addonStatus(element, "Active memory")).toBe("Loading…")); + + second.resolve({ plugins: [addon("active-memory", true)] }); + await waitForFast(() => expect(addonStatus(element, "Active memory")).toBe("Enabled")); + + first.resolve({ plugins: [addon("active-memory", false)] }); + await first.promise; + await element.updateComplete; + expect(addonStatus(element, "Active memory")).toBe("Enabled"); + } finally { + element.remove(); + } + }); + + it("marks add-ons unknown while disconnected", async () => { + const { element, setPhase } = createPage({ + configObject: {}, + catalog: [addon("active-memory", true)], + }); + document.body.append(element); + try { + await waitForFast(() => expect(addonStatus(element, "Active memory")).toBe("Enabled")); + setPhase("disconnected"); + await element.updateComplete; + expect(addonStatus(element, "Active memory")).toBe("Unknown"); + } finally { + element.remove(); + } + }); +}); + +describe("MemorySettingsPage tab routing", () => { + it("honors every ?tab= arrival, including a repeat after a manual tab change", async () => { + const navigate = vi.fn(); + const { element } = createPage({ configObject: {}, catalog: [], navigate }); + element.tab = "search"; + document.body.append(element); + try { + await element.updateComplete; + expect(visibleTab(element)).toBe("search"); + + // A manual click rewrites the URL rather than shadowing it with local state. + selectTab(element, "overview"); + expect(navigate).toHaveBeenCalledWith("memory", undefined); + element.tab = null; + await element.updateComplete; + expect(visibleTab(element)).toBe("overview"); + + // Same intent as the first arrival: an adopt-once page would ignore this. + element.tab = "search"; + await element.updateComplete; + expect(visibleTab(element)).toBe("search"); + } finally { + element.remove(); + } + }); + + it("writes the chosen tab into the URL so history restores it", async () => { + const navigate = vi.fn(); + const { element } = createPage({ configObject: {}, catalog: [], navigate }); + document.body.append(element); + try { + await element.updateComplete; + expect(visibleTab(element)).toBe("overview"); + + selectTab(element, "dreaming"); + expect(navigate).toHaveBeenCalledWith("memory", { search: "?tab=dreaming" }); + // Nothing moves until the router feeds the new tab back in. + await element.updateComplete; + expect(visibleTab(element)).toBe("overview"); + } finally { + element.remove(); + } + }); +}); diff --git a/ui/src/pages/config/memory-page.ts b/ui/src/pages/config/memory-page.ts new file mode 100644 index 000000000000..5ef263795533 --- /dev/null +++ b/ui/src/pages/config/memory-page.ts @@ -0,0 +1,295 @@ +// Controller for the curated Memory settings page. Owns the plugin catalog read +// used for the exclusive engine choice and the two config writes above the +// embedded schema editor; the tab lives in the URL, not here. +import { consume } from "@lit/context"; +import { html, type TemplateResult } from "lit"; +import { property, state } from "lit/decorators.js"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import { + loadPluginCatalog, + setPluginEnabled, + type PluginCatalogItem, +} from "../../lib/plugins/index.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import "./memory-dreaming-page.ts"; +import { + memorySchemaKeysForTab, + resolveMemoryBackend, + resolveMemoryEngineSelection, + selectedEngineId, + type MemoryEngineSelection, + type MemoryTab, +} from "./memory-schema.ts"; +import { + renderMemory, + type MemoryAddonRow, + type MemoryEngineOption, + type MemoryPluginState, +} from "./memory.ts"; + +// Curated presentation list. These bundled plugins declare no manifest `kind`, +// so nothing in plugin metadata marks them as memory add-ons; the exclusive +// engine below is still resolved through `plugins.slots.memory`, never by id. +const MEMORY_ADDON_PLUGINS = [ + { id: "active-memory", labelKey: "memoryPage.addons.activeMemory.title" }, + { id: "memory-wiki", labelKey: "memoryPage.addons.memoryWiki.title" }, +] as const; + +/** Explicit-off sentinel; resolveSlotSelection maps it to an `off` selection. */ +const MEMORY_SLOT_OFF = "none"; + +const MEMORY_SLOT_PATH = ["plugins", "slots", "memory"]; + +type GatewayClient = NonNullable; + +/** + * One gateway connection phase. `syncCatalog` mints a fresh object per (client, + * connected) transition and an in-flight load carries it, so the object identity + * is the request generation: a completion that started under an older phase is + * dropped instead of repopulating a disconnected page or clobbering a newer read. + */ +type CatalogConnection = { + client: GatewayClient | null; + connected: boolean; +}; + +/** + * What the page knows about the plugin catalog. Absence of an entry only means + * "not enabled" inside `ready`; every other state is genuinely unknown and must + * not be rendered as a decided value. + */ +type MemoryCatalog = + | { kind: "loading" } + | { kind: "unavailable" } + | { kind: "ready"; plugins: readonly PluginCatalogItem[] }; + +type MemoryPageProps = { + configObject: Record; + pluginsHref: string; + memoryImportHref: string; + /** The `?tab=` the URL currently describes; the page holds no tab state of its own. */ + tab: MemoryTab | null; + /** Builds the embedded schema editor over the given `memory.*` children. */ + buildEditor: (keys: readonly string[]) => TemplateResult; +}; + +function isMemoryEngine(plugin: PluginCatalogItem): boolean { + return plugin.installed && plugin.kind?.includes("memory") === true; +} + +function pluginState( + catalog: MemoryCatalog, + entry: PluginCatalogItem | undefined, +): MemoryPluginState { + switch (catalog.kind) { + case "loading": + return "loading"; + case "unavailable": + return "unknown"; + default: + return entry?.enabled === true ? "enabled" : "disabled"; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +class MemorySettingsPage extends OpenClawLightDomElement { + @consume({ context: applicationContext, subscribe: true }) + private context!: ApplicationContext; + + @property({ attribute: false }) configObject: Record = {}; + @property() pluginsHref = ""; + @property() memoryImportHref = ""; + @property({ attribute: false }) tab: MemoryTab | null = null; + @property({ attribute: false }) buildEditor: MemoryPageProps["buildEditor"] = () => html``; + + @state() private catalog: MemoryCatalog = { kind: "unavailable" }; + @state() private engineBusy = false; + @state() private engineError: string | null = null; + + private connection: CatalogConnection | null = null; + private readonly subscriptions = new SubscriptionsController(this).watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.syncCatalog(gateway.snapshot.client, gateway.snapshot.phase === "connected"), + ); + + override disconnectedCallback() { + this.subscriptions.clear(); + this.connection = null; + this.catalog = { kind: "unavailable" }; + super.disconnectedCallback(); + } + + private syncCatalog(client: GatewayClient | null, connected: boolean) { + // The connecting -> connected transition keeps the same client object, so + // keying only on client identity would strand the catalog empty for a page + // mounted before the handshake finished. + if (this.connection?.client === client && this.connection.connected === connected) { + return; + } + const connection: CatalogConnection = { client, connected }; + this.connection = connection; + if (!client || !connected) { + this.catalog = { kind: "unavailable" }; + return; + } + this.catalog = { kind: "loading" }; + void this.loadCatalog(client, connection); + } + + private async loadCatalog(client: GatewayClient, connection: CatalogConnection) { + try { + const result = await loadPluginCatalog(client); + this.applyCatalog(connection, { kind: "ready", plugins: result.plugins }); + } catch { + // The catalog is a convenience for the engine picker; the page still + // renders the configured slot id when it cannot be read. + this.applyCatalog(connection, { kind: "unavailable" }); + } + } + + private applyCatalog(connection: CatalogConnection, catalog: MemoryCatalog) { + if (!this.isConnected || this.connection !== connection) { + return; + } + this.catalog = catalog; + } + + private engineOptions(): MemoryEngineOption[] { + if (this.catalog.kind !== "ready") { + return []; + } + return this.catalog.plugins + .filter(isMemoryEngine) + .map((plugin) => ({ id: plugin.id, label: plugin.name })) + .toSorted((left, right) => left.label.localeCompare(right.label)); + } + + /** Catalog verdict on the plugin the slot names; `off` has no owner to report. */ + private engineState(selection: MemoryEngineSelection): MemoryPluginState { + const engineId = selectedEngineId(selection); + if (engineId === null) { + return "unknown"; + } + const catalog = this.catalog; + const entry = + catalog.kind === "ready" + ? catalog.plugins.find((plugin) => plugin.id === engineId) + : undefined; + return pluginState(catalog, entry); + } + + private addonRows(): MemoryAddonRow[] { + const catalog = this.catalog; + return MEMORY_ADDON_PLUGINS.map((addon) => { + const entry = + catalog.kind === "ready" + ? catalog.plugins.find((plugin) => plugin.id === addon.id) + : undefined; + return { + id: addon.id, + label: t(addon.labelKey), + description: entry?.description ?? addon.id, + state: pluginState(catalog, entry), + }; + }); + } + + /** + * Picking an engine goes through plugins.setEnabled so the gateway's exclusive + * slot policy (applySlotSelectionForPlugin) stays the single owner of pinning. + * That RPC only writes plugin enablement, so Off has to write the slot itself: + * disabling the current owner would leave a pinned slot behind, and re-enabling + * that plugin anywhere else would silently switch memory back on. + */ + private async changeEngine(engineId: string | null, currentSelection: MemoryEngineSelection) { + if (this.engineBusy) { + return; + } + // Re-picking the current selection is a no-op only when it is already in + // effect: Off always is, but a named owner the catalog reports as disabled + // is not, and re-picking it is the one path that turns it back on. + if (engineId === selectedEngineId(currentSelection)) { + if (engineId === null || this.engineState(currentSelection) === "enabled") { + return; + } + } + if (!engineId) { + this.engineError = null; + this.context.runtimeConfig.patchForm(MEMORY_SLOT_PATH, MEMORY_SLOT_OFF); + return; + } + const connection = this.connection; + const client = connection?.connected ? connection.client : null; + if (!connection || !client) { + return; + } + this.engineBusy = true; + this.engineError = null; + try { + await setPluginEnabled(client, engineId, true); + await this.context.runtimeConfig.refresh(); + // Reuses the same generation guard: a connection change mid-write drops + // the reload instead of writing a catalog the page no longer owns. + await this.loadCatalog(client, connection); + } catch (error) { + // Without this the selector just snaps back to the old engine and the + // operator has no idea the gateway rejected the change. + this.engineError = errorMessage(error); + } finally { + this.engineBusy = false; + } + } + + override render() { + const runtimeConfig = this.context.runtimeConfig; + const options = this.engineOptions(); + const engineSelection = resolveMemoryEngineSelection(this.configObject); + const backend = resolveMemoryBackend(this.configObject); + const activeTab = this.tab ?? "overview"; + return renderMemory({ + activeTab, + // The URL is the only tab state, so every arrival honors its `?tab=` — + // including a repeat of one the user has since navigated away from — and + // history back/forward restores the tab the URL describes. + onTabChange: (tab) => { + this.context.navigate("memory", tab === "overview" ? undefined : { search: `?tab=${tab}` }); + }, + engineOptions: options, + engineSelection, + engineState: this.engineState(engineSelection), + engineBusy: this.engineBusy, + engineError: this.engineError, + onEngineChange: (nextEngineId) => void this.changeEngine(nextEngineId, engineSelection), + backend, + backendBusy: runtimeConfig.state.configSaving || runtimeConfig.state.configApplying, + onBackendChange: (next) => runtimeConfig.patchForm(["memory", "backend"], next), + addons: this.addonRows(), + pluginsHref: this.pluginsHref, + memoryImportHref: this.memoryImportHref, + editor: this.buildEditor(memorySchemaKeysForTab(activeTab, backend)), + dreaming: html``, + }); + } +} + +if (!customElements.get("openclaw-memory-settings")) { + customElements.define("openclaw-memory-settings", MemorySettingsPage); +} + +export function renderMemoryPage(props: MemoryPageProps) { + return html` + + `; +} diff --git a/ui/src/pages/config/memory-schema.ts b/ui/src/pages/config/memory-schema.ts new file mode 100644 index 000000000000..ac62da8e51b0 --- /dev/null +++ b/ui/src/pages/config/memory-schema.ts @@ -0,0 +1,155 @@ +// Config facts about the `memory` section, with no rendering imports. +// +// The Memory page is behind the lazy `import("./config-page.ts")` route, but +// settings search runs from app-host at startup. Both need the same answers +// about which `memory.*` children are reachable and where a match lives, so +// those answers live here rather than in the view module — importing the view +// from search would pull lit, hub-tabs, and settings-ui into the startup chunk. +import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; +import { resolveSlotSelection } from "../../../../src/plugins/slots.ts"; + +export type MemoryTab = "overview" | "search" | "dreaming"; + +export type MemoryBackend = "builtin" | "qmd"; + +/** + * How `plugins.slots.memory` reads today, mirroring resolveSlotSelection in + * src/plugins/slots.ts. `off` is the explicit `none` sentinel; `auto` is an + * unset slot, which always resolves to the slot's default owner rather than to + * whichever memory plugin happens to be enabled. + */ +export type MemoryEngineSelection = + | { kind: "auto"; engineId: string } + | { kind: "off" } + | { kind: "pinned"; engineId: string }; + +/** Scroll target for `memory.backend`, which Overview curates out of the editor. */ +export const MEMORY_BACKEND_ANCHOR_ID = "memory-backend"; + +const MEMORY_TABS: readonly MemoryTab[] = ["overview", "search", "dreaming"]; + +/** Reads a `?tab=` value from a settings-search destination or a shared link. */ +export function normalizeMemoryTab(value: string | null | undefined): MemoryTab | null { + return MEMORY_TABS.find((tab) => tab === value) ?? null; +} + +/** The plugin that currently owns the slot, or null when nothing does. */ +export function selectedEngineId(selection: MemoryEngineSelection): string | null { + return selection.kind === "off" ? null : selection.engineId; +} + +// memory-core is the only plugin registering the memory runtime that resolves +// `memory.backend`, so any other engine hides the backend row. This is a +// runtime-ownership fact, not the slot default; the slot comes from +// resolveSlotSelection. +const MEMORY_CORE_PLUGIN_ID = "memory-core"; + +/** + * Mirrors the runtime exactly: resolveSlotSelection owns the rule, so an unset + * slot reports the slot's default owner instead of guessing from the catalog. + */ +export function resolveMemoryEngineSelection( + configObject: Record, +): MemoryEngineSelection { + const slots = asConfigRecord(asConfigRecord(configObject.plugins)?.slots); + const selection = resolveSlotSelection("memory", slots?.memory); + switch (selection.kind) { + case "off": + return { kind: "off" }; + case "pinned": + return { kind: "pinned", engineId: selection.pluginId }; + default: + return { kind: "auto", engineId: selection.pluginId }; + } +} + +/** + * The retrieval backend the page shows, or null when the slot owner runs its own + * retrieval and `memory.backend` would save a value nothing consumes. Settings + * search resolves it from the same config so both agree on what is visible. + */ +export function resolveMemoryBackend(configObject: Record): MemoryBackend | null { + if (selectedEngineId(resolveMemoryEngineSelection(configObject)) !== MEMORY_CORE_PLUGIN_ID) { + return null; + } + return asConfigRecord(configObject.memory)?.backend === "qmd" ? "qmd" : "builtin"; +} + +type JsonRecord = Record; + +function asJsonRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +// One narrowed schema object per (source schema, key set): the config view caches +// its schema analysis by object identity, so a fresh clone per render would +// re-analyze the whole tree on every update. +const narrowedMemorySchemas = new WeakMap>(); + +/** + * Restrict the root config schema to `memory` with only `keys` retained, so one + * page can host several tabs over disjoint slices of the same schema section. + */ +export function narrowMemorySchema(schema: unknown, keys: readonly string[]): unknown { + const root = asJsonRecord(schema); + const memorySchema = asJsonRecord(asJsonRecord(root?.properties)?.memory); + const memoryProperties = asJsonRecord(memorySchema?.properties); + if (!root || !memorySchema || !memoryProperties) { + return schema; + } + const cacheKey = keys.join(""); + const bucket = narrowedMemorySchemas.get(root) ?? new Map(); + const hit = bucket.get(cacheKey); + if (hit !== undefined) { + return hit; + } + const retained = Object.fromEntries( + keys.filter((key) => key in memoryProperties).map((key) => [key, memoryProperties[key]]), + ); + const narrowed = { + ...root, + properties: { memory: { ...memorySchema, properties: retained } }, + }; + bucket.set(cacheKey, narrowed); + narrowedMemorySchemas.set(root, bucket); + return narrowed; +} + +/** + * The `memory.*` children only the Search tab renders; every other child of the + * section belongs to Overview. Settings search routes deep links through this so + * a match cannot land on a tab whose editor omits the field it matched. + */ +export const MEMORY_SEARCH_TAB_SCHEMA_KEYS: readonly string[] = ["search"]; + +/** + * The `memory.*` children Overview renders as curated rows instead of through + * the embedded editor. They have no `#config-section-*` id, so settings search + * routes their deep links to MEMORY_BACKEND_ANCHOR_ID. + */ +export const MEMORY_CURATED_SCHEMA_KEYS: readonly string[] = ["backend"]; + +/** Which `memory.*` children the embedded editor shows for a tab. */ +export function memorySchemaKeysForTab( + tab: MemoryTab, + backend: MemoryBackend | null, +): readonly string[] { + if (tab === "search") { + return MEMORY_SEARCH_TAB_SCHEMA_KEYS; + } + // `backend` is a curated row above the editor; qmd's sub-config only matters + // once qmd is the selected backend. + return backend === "qmd" ? ["citations", "qmd"] : ["citations"]; +} + +/** + * Every `memory.*` child the page surfaces for a config: both editor slices plus + * `backend`, which Overview renders as a curated row rather than through the + * editor. `qmd` and `backend` disappear with the backend/engine choice, so + * settings search filters the section through this before matching — otherwise a + * `memory.qmd` hit routes to an Overview whose editor omits it. + */ +export function memoryVisibleSchemaKeys(backend: MemoryBackend | null): readonly string[] { + const editor = [...MEMORY_SEARCH_TAB_SCHEMA_KEYS, ...memorySchemaKeysForTab("overview", backend)]; + return backend === null ? editor : [...editor, "backend"]; +} diff --git a/ui/src/pages/config/memory.test.ts b/ui/src/pages/config/memory.test.ts new file mode 100644 index 000000000000..f72d270957dc --- /dev/null +++ b/ui/src/pages/config/memory.test.ts @@ -0,0 +1,221 @@ +/* @vitest-environment jsdom */ + +import { html, render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { + memorySchemaKeysForTab, + memoryVisibleSchemaKeys, + narrowMemorySchema, + resolveMemoryBackend, +} from "./memory-schema.ts"; +import { renderMemory } from "./memory.ts"; + +/** The view is the only public surface, so its props type comes from its signature. */ +type MemoryViewProps = Parameters[0]; + +function createProps(overrides: Partial = {}): MemoryViewProps { + return { + activeTab: "overview", + onTabChange: vi.fn(), + engineOptions: [ + { id: "memory-core", label: "Memory Core" }, + { id: "memory-lancedb", label: "Memory LanceDB" }, + ], + engineSelection: { kind: "auto", engineId: "memory-core" }, + engineState: "enabled", + engineBusy: false, + engineError: null, + onEngineChange: vi.fn(), + backend: "builtin", + backendBusy: false, + onBackendChange: vi.fn(), + addons: [ + { + id: "active-memory", + label: "Active memory", + description: "Recent context", + state: "enabled", + }, + { id: "memory-wiki", label: "Memory wiki", description: "Wiki pages", state: "disabled" }, + ], + pluginsHref: "/settings/plugins", + memoryImportHref: "/memory-import", + editor: html`
`, + dreaming: html`
`, + ...overrides, + }; +} + +function renderInto(props: MemoryViewProps): HTMLElement { + const container = document.createElement("div"); + render(renderMemory(props), container); + return container; +} + +describe("renderMemory", () => { + it("shows the exclusive engine choice as one radio group over installed engines", () => { + const container = renderInto(createProps()); + + const group = container.querySelector("wa-radio-group.settings-segmented"); + expect(group).not.toBeNull(); + const values = [...container.querySelectorAll("wa-radio")].map((radio) => + radio.getAttribute("value"), + ); + expect(values).toContain("memory-core"); + expect(values).toContain("memory-lancedb"); + // The trailing empty value switches the memory slot off entirely. + expect(values).toContain(""); + }); + + it("reports whether the engine came from config or from the slot default", () => { + const auto = renderInto(createProps()); + expect(auto.textContent).toContain("falls back to its default owner"); + + const pinned = renderInto( + createProps({ engineSelection: { kind: "pinned", engineId: "memory-core" } }), + ); + expect(pinned.textContent).toContain("pinned in config"); + }); + + it("surfaces a failed engine write next to the control", () => { + expect(renderInto(createProps()).textContent).not.toContain("Could not change"); + + const failed = renderInto(createProps({ engineError: "gateway rejected the change" })); + expect(failed.textContent).toContain("Could not change the memory engine"); + expect(failed.textContent).toContain("gateway rejected the change"); + }); + + it("selects the Off option and says so for an explicit plugins.slots.memory none", () => { + const container = renderInto(createProps({ engineSelection: { kind: "off" } })); + + const active = container.querySelector("wa-radio.settings-segmented__btn--active"); + expect(active?.getAttribute("value")).toBe(""); + expect(container.textContent).toContain("switched off"); + expect(container.textContent).not.toContain("pinned in config"); + }); + + it("hides the retrieval backend row for an engine that owns its own retrieval", () => { + expect(renderInto(createProps({ backend: "builtin" })).textContent).toContain( + "Retrieval backend", + ); + expect(renderInto(createProps({ backend: null })).textContent).not.toContain( + "Retrieval backend", + ); + }); + + it("renders add-on layering with per-plugin state and a Plugins link", () => { + const container = renderInto(createProps()); + + expect(container.textContent).toContain("Active memory"); + expect(container.textContent).toContain("Memory wiki"); + expect(container.textContent).toContain("Enabled"); + expect(container.textContent).toContain("Disabled"); + const link = container.querySelector("a.memory-page__link"); + expect(link?.getAttribute("href")).toBe("/settings/plugins"); + }); + + it("never states an add-on is off while the catalog is unread", () => { + for (const state of ["loading", "unknown"] as const) { + const container = renderInto( + createProps({ + addons: [{ id: "active-memory", label: "Active memory", description: "x", state }], + }), + ); + expect(container.textContent).not.toContain("Disabled"); + expect(container.textContent).not.toContain("Enabled"); + } + }); + + it("keeps the schema editor on the overview and search tabs and swaps in dreaming", () => { + expect(renderInto(createProps()).querySelector(".test-editor")).not.toBeNull(); + expect( + renderInto(createProps({ activeTab: "search" })).querySelector(".test-editor"), + ).not.toBeNull(); + + const dreaming = renderInto(createProps({ activeTab: "dreaming" })); + expect(dreaming.querySelector(".test-dreaming")).not.toBeNull(); + expect(dreaming.querySelector(".test-editor")).toBeNull(); + }); +}); + +describe("memorySchemaKeysForTab", () => { + it("reveals qmd sub-config only when qmd is the selected backend", () => { + expect(memorySchemaKeysForTab("overview", "builtin")).toEqual(["citations"]); + expect(memorySchemaKeysForTab("overview", "qmd")).toEqual(["citations", "qmd"]); + expect(memorySchemaKeysForTab("search", "qmd")).toEqual(["search"]); + // No applicable backend: qmd's sub-config belongs to a backend nothing reads. + expect(memorySchemaKeysForTab("overview", null)).toEqual(["citations"]); + }); +}); + +describe("memoryVisibleSchemaKeys", () => { + it("hides qmd until qmd is the selected backend and backend when no engine reads it", () => { + expect([...memoryVisibleSchemaKeys("builtin")].toSorted()).toEqual([ + "backend", + "citations", + "search", + ]); + expect([...memoryVisibleSchemaKeys("qmd")].toSorted()).toEqual([ + "backend", + "citations", + "qmd", + "search", + ]); + expect([...memoryVisibleSchemaKeys(null)].toSorted()).toEqual(["citations", "search"]); + }); +}); + +describe("resolveMemoryBackend", () => { + it("reports a backend only for the memory-core slot owner", () => { + expect(resolveMemoryBackend({})).toBe("builtin"); + expect(resolveMemoryBackend({ memory: { backend: "qmd" } })).toBe("qmd"); + // Another engine owns the slot, so nothing reads memory.backend. + expect( + resolveMemoryBackend({ + memory: { backend: "qmd" }, + plugins: { slots: { memory: "memory-lancedb" } }, + }), + ).toBeNull(); + expect(resolveMemoryBackend({ plugins: { slots: { memory: "none" } } })).toBeNull(); + }); +}); + +describe("narrowMemorySchema", () => { + const schema = { + type: "object", + properties: { + memory: { + type: "object", + properties: { + backend: { type: "string" }, + citations: { type: "string" }, + search: { type: "object" }, + qmd: { type: "object" }, + }, + }, + tools: { type: "object" }, + }, + }; + + it("keeps only the requested memory children and drops sibling sections", () => { + const narrowed = narrowMemorySchema(schema, ["search"]) as { + properties: { memory: { properties: Record }; tools?: unknown }; + }; + + expect(Object.keys(narrowed.properties)).toEqual(["memory"]); + expect(Object.keys(narrowed.properties.memory.properties)).toEqual(["search"]); + }); + + it("returns a stable object per key set so schema analysis stays cached", () => { + expect(narrowMemorySchema(schema, ["search"])).toBe(narrowMemorySchema(schema, ["search"])); + expect(narrowMemorySchema(schema, ["search"])).not.toBe( + narrowMemorySchema(schema, ["citations"]), + ); + }); + + it("passes non-memory schemas through untouched", () => { + const unrelated = { type: "object", properties: { tools: {} } }; + expect(narrowMemorySchema(unrelated, ["search"])).toBe(unrelated); + expect(narrowMemorySchema(null, ["search"])).toBeNull(); + }); +}); diff --git a/ui/src/pages/config/memory.ts b/ui/src/pages/config/memory.ts new file mode 100644 index 000000000000..3cf43dcc99b5 --- /dev/null +++ b/ui/src/pages/config/memory.ts @@ -0,0 +1,276 @@ +// Curated Memory home: engine/backend/add-on rows above the embedded memory +// schema editor, with Dreaming as a sibling tab (see security.ts for the same +// curated-rows-above-schema shape). +import { html, nothing, type TemplateResult } from "lit"; +import { renderHubTabs } from "../../components/hub-tabs.ts"; +import { + renderSettingsRow, + renderSettingsSection, + renderSettingsSegmented, + renderSettingsStatus, + renderSettingsValue, +} from "../../components/settings-ui.ts"; +import { t } from "../../i18n/index.ts"; +import { + selectedEngineId, + MEMORY_BACKEND_ANCHOR_ID, + type MemoryBackend, + type MemoryEngineSelection, + type MemoryTab, +} from "./memory-schema.ts"; + +/** One installed plugin that can claim the exclusive `plugins.slots.memory` slot. */ +export type MemoryEngineOption = { + id: string; + label: string; +}; + +/** + * Enablement as the page actually knows it, shared by the engine row and the + * add-on rows. `loading` and `unknown` exist so a catalog that was never read + * cannot render as a definite "Disabled"; only a successful read decides. + */ +export type MemoryPluginState = "enabled" | "disabled" | "loading" | "unknown"; + +/** Additive memory plugin: no `kind`, so it layers on top of whichever engine wins the slot. */ +export type MemoryAddonRow = { + id: string; + label: string; + description: string; + state: MemoryPluginState; +}; + +type MemoryViewProps = { + activeTab: MemoryTab; + onTabChange: (tab: MemoryTab) => void; + engineOptions: readonly MemoryEngineOption[]; + engineSelection: MemoryEngineSelection; + /** + * What the catalog says about the plugin the slot names. The slot and plugin + * enablement are independent config surfaces, so the named owner can be + * disabled and memory silently off; only `enabled` means it is running. + */ + engineState: MemoryPluginState; + engineBusy: boolean; + /** Last failed engine write, so a rejected change is not just a snap-back. */ + engineError: string | null; + onEngineChange: (engineId: string | null) => void; + /** null when the slot owner runs its own retrieval, so this row does not apply. */ + backend: MemoryBackend | null; + backendBusy: boolean; + onBackendChange: (backend: MemoryBackend) => void; + addons: readonly MemoryAddonRow[]; + pluginsHref: string; + memoryImportHref: string; + /** Embedded schema editor for this tab's slice of the `memory` section. */ + editor: TemplateResult; + /** Dreaming tab body; owns its own agent picker and per-agent reads. */ + dreaming: TemplateResult; +}; + +const MEMORY_PANEL_ID = "memory-settings-panel"; + +const MEMORY_ENGINE_OFF = ""; + +function engineHintKey(selection: MemoryEngineSelection): string { + switch (selection.kind) { + case "auto": + return "memoryPage.engine.autoHint"; + case "off": + return "memoryPage.engine.offHint"; + default: + return "memoryPage.engine.explicitHint"; + } +} + +function renderEngineSection(props: MemoryViewProps) { + // The slot is exclusive (resolveMemorySlotDecisionShared): only one memory-kind + // plugin loads. A segmented control states that up front instead of leaving it + // to a post-save toast. + const engineId = selectedEngineId(props.engineSelection); + if (props.engineOptions.length === 0) { + return renderSettingsSection( + { title: t("memoryPage.engine.title"), description: t("memoryPage.engine.description") }, + renderSettingsRow({ + title: t("memoryPage.engine.rowTitle"), + description: t("memoryPage.engine.catalogUnavailable"), + control: renderSettingsValue(engineId ?? t("memoryPage.engine.off"), { + mono: true, + }), + }), + ); + } + const options = [ + ...props.engineOptions.map((option) => ({ value: option.id, label: option.label })), + { value: MEMORY_ENGINE_OFF, label: t("memoryPage.engine.off") }, + ]; + return renderSettingsSection( + { title: t("memoryPage.engine.title"), description: t("memoryPage.engine.description") }, + html` + ${renderSettingsRow({ + title: t("memoryPage.engine.rowTitle"), + description: t(engineHintKey(props.engineSelection)), + stacked: true, + control: renderSettingsSegmented({ + value: engineId ?? MEMORY_ENGINE_OFF, + options, + disabled: props.engineBusy, + ariaLabel: t("memoryPage.engine.rowTitle"), + onChange: (value) => props.onEngineChange(value || null), + }), + })} + ${renderDisabledEngineRow(props, engineId)} + ${props.engineError === null + ? nothing + : renderSettingsRow({ + title: t("memoryPage.engine.changeFailed"), + description: props.engineError, + control: renderSettingsStatus({ kind: "danger", label: t("common.failed") }), + })} + `, + ); +} + +/** + * The segmented control shows the slot owner, which stays selected even when + * that plugin is disabled — so re-picking it fires no change event and there + * would be no way back. This row is the only path that re-enables the owner. + */ +function renderDisabledEngineRow(props: MemoryViewProps, engineId: string | null) { + if (engineId === null || props.engineState !== "disabled") { + return nothing; + } + return renderSettingsRow({ + title: t("memoryPage.engine.disabledTitle"), + description: t("memoryPage.engine.disabledHint"), + control: html` + + `, + }); +} + +function renderBackendSection(props: MemoryViewProps) { + // builtin/qmd is resolved by the memory runtime the slot owner registers + // (resolveActiveMemoryBackendConfig in src/plugins/memory-runtime.ts). An + // engine that registers none ignores it, so the row must not appear there. + if (props.backend === null) { + return nothing; + } + // Anchor target for settings search: `backend` is curated out of the schema + // editor, so it has no `#config-section-*` id of its own to scroll to. + return html`
+ ${renderSettingsSection( + { title: t("memoryPage.backend.title"), description: t("memoryPage.backend.description") }, + renderSettingsRow({ + title: t("memoryPage.backend.rowTitle"), + description: + props.backend === "qmd" + ? t("memoryPage.backend.qmdHint") + : t("memoryPage.backend.builtinHint"), + stacked: true, + control: renderSettingsSegmented({ + value: props.backend, + options: [ + { value: "builtin", label: t("memoryPage.backend.builtin") }, + { value: "qmd", label: t("memoryPage.backend.qmd") }, + ], + disabled: props.backendBusy, + ariaLabel: t("memoryPage.backend.rowTitle"), + onChange: (value) => props.onBackendChange(value), + }), + }), + )} +
`; +} + +// Only `enabled` is a positive claim; the other three are deliberately muted so +// an unread catalog never looks like a decided "off". +function renderAddonStatus(state: MemoryPluginState) { + switch (state) { + case "enabled": + return renderSettingsStatus({ kind: "ok", label: t("common.enabled") }); + case "disabled": + return renderSettingsStatus({ kind: "muted", label: t("common.disabled") }); + case "loading": + return renderSettingsStatus({ kind: "muted", label: t("common.loading") }); + default: + return renderSettingsStatus({ kind: "muted", label: t("memoryPage.addons.stateUnknown") }); + } +} + +function renderAddonsSection(props: MemoryViewProps) { + return renderSettingsSection( + { title: t("memoryPage.addons.title"), description: t("memoryPage.addons.description") }, + html` + ${props.addons.map((addon) => + renderSettingsRow({ + title: addon.label, + description: addon.description, + control: renderAddonStatus(addon.state), + }), + )} + ${renderSettingsRow({ + title: t("memoryPage.addons.manage"), + control: html`${t("memoryPage.addons.manageLink")}`, + })} + `, + ); +} + +function renderOverviewTab(props: MemoryViewProps) { + return html` +
+ ${renderEngineSection(props)} ${renderBackendSection(props)} ${renderAddonsSection(props)} + ${renderSettingsSection( + { title: t("memoryPage.import.title"), description: t("memoryPage.import.description") }, + renderSettingsRow({ + title: t("tabs.memoryImport"), + description: t("subtitles.memoryImport"), + control: html`${t("memoryPage.import.link")}`, + }), + )} +
+ ${props.editor} + `; +} + +export function renderMemory(props: MemoryViewProps) { + return html` +
+ ${renderHubTabs({ + id: "memory", + active: props.activeTab, + tabs: [ + { value: "overview", label: t("memoryPage.tabs.overview") }, + { value: "search", label: t("memoryPage.tabs.search") }, + { value: "dreaming", label: t("memoryPage.tabs.dreaming") }, + ], + ariaLabel: t("memoryPage.tablistLabel"), + panelId: MEMORY_PANEL_ID, + onSelect: (tab) => props.onTabChange(tab), + })} +
+ ${props.activeTab === "overview" + ? renderOverviewTab(props) + : props.activeTab === "search" + ? html` +
+

${t("memoryPage.search.intro")}

+
+ ${props.editor} + ` + : props.dreaming} +
+
+ `; +} diff --git a/ui/src/pages/config/route-data.test.ts b/ui/src/pages/config/route-data.test.ts index 185fe817666c..ad9fdbf579d1 100644 --- a/ui/src/pages/config/route-data.test.ts +++ b/ui/src/pages/config/route-data.test.ts @@ -11,6 +11,7 @@ describe("config route data", () => { ).toEqual({ section: "browser", advanced: false, + tab: null, targetBlockId: "config-section-browser/profiles", }); }); @@ -20,6 +21,7 @@ describe("config route data", () => { expect(configRouteData({ search: "", hash: "#%" })).toEqual({ section: null, advanced: false, + tab: null, targetBlockId: null, }); }); @@ -28,6 +30,16 @@ describe("config route data", () => { expect(configRouteData({ search: "?section=gateway&advanced=1", hash: "" })).toEqual({ section: "gateway", advanced: true, + tab: null, + targetBlockId: null, + }); + }); + + it("carries the hub tab a settings-search destination asks for", () => { + expect(configRouteData({ search: "?section=memory&tab=search", hash: "" })).toEqual({ + section: "memory", + advanced: false, + tab: "search", targetBlockId: null, }); }); diff --git a/ui/src/pages/config/route-data.ts b/ui/src/pages/config/route-data.ts index 85d91c22ce16..ccfecd05be22 100644 --- a/ui/src/pages/config/route-data.ts +++ b/ui/src/pages/config/route-data.ts @@ -3,6 +3,8 @@ import type { RouteLocation } from "@openclaw/uirouter"; export type ConfigRouteData = { section: string | null; advanced: boolean; + /** Raw `?tab=`; curated hub pages normalize it against their own tab set. */ + tab: string | null; targetBlockId: string | null; }; @@ -23,6 +25,7 @@ export function configRouteData(location: Pick return { section, advanced: searchParams.get("advanced") === "1", + tab: searchParams.get("tab")?.trim() || null, targetBlockId: configTargetIdFromHash(location.hash), }; } diff --git a/ui/src/pages/config/route.ts b/ui/src/pages/config/route.ts index df743fc90414..e10395d2c392 100644 --- a/ui/src/pages/config/route.ts +++ b/ui/src/pages/config/route.ts @@ -37,6 +37,7 @@ export const pages = [ configPage("security", "/settings/security", []), configPage("automation", "/settings/automation", ["/automation"]), configPage("mcp", "/settings/mcp", ["/mcp"]), + configPage("memory", "/settings/memory", []), configPage("infrastructure", "/settings/infrastructure", ["/infrastructure"]), configPage("ai-agents", "/settings/ai-agents", ["/ai-agents"]), configPage("advanced", "/settings/advanced", []), diff --git a/ui/src/pages/config/settings-search.test.ts b/ui/src/pages/config/settings-search.test.ts index a9660503493f..3bc1ba12178e 100644 --- a/ui/src/pages/config/settings-search.test.ts +++ b/ui/src/pages/config/settings-search.test.ts @@ -59,6 +59,134 @@ describe("findSettingsSearchBlocks", () => { ]); }); + it("routes a curated backend match to the anchor above the editor", () => { + const matches = findSettingsSearchBlocks({ + query: "backend", + schema: { + type: "object", + properties: { + memory: { + type: "object", + properties: { + backend: { type: "string", title: "Backend" }, + }, + }, + }, + }, + value: { memory: { backend: "builtin" } }, + uiHints: { "memory.backend": { advanced: false } }, + }); + + // `backend` is curated out of the schema editor, so #config-section-memory + // would scroll past the control the searcher matched. + expect(matches).toEqual([ + expect.objectContaining({ + routeId: "memory", + search: "?section=memory", + hash: "#memory-backend", + }), + ]); + }); + + it("opens the Memory page on the tab whose editor renders the matched field", () => { + const memorySchema = { + type: "object", + properties: { + memory: { + type: "object", + properties: { + backend: { type: "string", title: "Backend" }, + search: { + type: "object", + properties: { embeddingModel: { type: "string", title: "Embedding model" } }, + }, + }, + }, + }, + }; + const uiHints = { + "memory.backend": { advanced: false }, + "memory.search": { advanced: false }, + "memory.search.embeddingModel": { advanced: false }, + }; + + // Only the Search tab renders memory.search; Overview would show nothing. + const searchOnly = findSettingsSearchBlocks({ + query: "embedding model", + schema: memorySchema, + value: {}, + uiHints, + }); + expect(searchOnly).toEqual([ + expect.objectContaining({ routeId: "memory", search: "?section=memory&tab=search" }), + ]); + + // A section-level hit is not exclusive to one tab or to the curated rows, so + // it keeps the default Overview editor destination. + const sectionWide = findSettingsSearchBlocks({ + query: "memory", + schema: memorySchema, + value: {}, + uiHints, + }).filter((block) => block.routeId === "memory"); + expect(sectionWide).toEqual([ + expect.objectContaining({ + routeId: "memory", + search: "?section=memory", + hash: "#config-section-memory", + }), + ]); + + // Curated-only: the anchor wins, and the search tab is not selected. + const backendOnly = findSettingsSearchBlocks({ + query: "backend", + schema: memorySchema, + value: {}, + uiHints, + }); + expect(backendOnly).toEqual([ + expect.objectContaining({ + routeId: "memory", + search: "?section=memory", + hash: "#memory-backend", + }), + ]); + }); + + it("offers memory.qmd only while qmd is the backend the page reveals", () => { + const memorySchema = { + type: "object", + properties: { + memory: { + type: "object", + properties: { + backend: { type: "string", title: "Backend" }, + qmd: { + type: "object", + properties: { binaryPath: { type: "string", title: "QMD binary path" } }, + }, + }, + }, + }, + }; + const uiHints = { + "memory.backend": { advanced: false }, + "memory.qmd": { advanced: false }, + "memory.qmd.binaryPath": { advanced: false }, + }; + const find = (value: Record) => + findSettingsSearchBlocks({ query: "qmd binary path", schema: memorySchema, value, uiHints }); + + // Overview's editor omits memory.qmd under the built-in backend, so a hit + // there would open a page that cannot show the matched field. + expect(find({ memory: { backend: "builtin" } })).toEqual([]); + // Another plugin owns the slot: memory.backend and its sub-config are unread. + expect(find({ plugins: { slots: { memory: "memory-lancedb" } } })).toEqual([]); + expect(find({ memory: { backend: "qmd" } })).toEqual([ + expect.objectContaining({ routeId: "memory", search: "?section=memory" }), + ]); + }); + it("routes moved static blocks to their dedicated pages", () => { const security = findSettingsSearchBlocks({ query: "exec policy", diff --git a/ui/src/pages/config/settings-search.ts b/ui/src/pages/config/settings-search.ts index 5cfc9c514581..255ca4d227dd 100644 --- a/ui/src/pages/config/settings-search.ts +++ b/ui/src/pages/config/settings-search.ts @@ -16,8 +16,16 @@ import { COMMUNICATION_SECTION_KEYS, INFRASTRUCTURE_SECTION_KEYS, MCP_SECTION_KEYS, + MEMORY_SECTION_KEYS, SECURITY_SECTION_KEYS, } from "./config-sections.ts"; +import { + memoryVisibleSchemaKeys, + resolveMemoryBackend, + MEMORY_BACKEND_ANCHOR_ID, + MEMORY_CURATED_SCHEMA_KEYS, + MEMORY_SEARCH_TAB_SCHEMA_KEYS, +} from "./memory-schema.ts"; import { APPEARANCE_SETTINGS_TARGET_IDS, COMMUNICATION_SETTINGS_TARGET_IDS, @@ -221,6 +229,7 @@ const APPEARANCE_SECTIONS = new Set(APPEARANCE_SECTION_KEYS); const SECURITY_SECTIONS = new Set(SECURITY_SECTION_KEYS); const AUTOMATION_SECTIONS = new Set(AUTOMATION_SECTION_KEYS); const MCP_SECTIONS = new Set(MCP_SECTION_KEYS); +const MEMORY_SECTIONS = new Set(MEMORY_SECTION_KEYS); const INFRASTRUCTURE_SECTIONS = new Set(INFRASTRUCTURE_SECTION_KEYS); const AI_AGENTS_SECTIONS = new Set(AI_AGENTS_SECTION_KEYS); @@ -234,10 +243,85 @@ function resolveStaticSettingsBlock(block: StaticSettingsBlockDescriptor): Stati }; } +/** + * The Memory page hides `memory.*` children the current engine/backend makes + * inapplicable — `memory.qmd` only renders once qmd is the selected backend. + * Matching the raw section would offer a destination whose editor omits the very + * field that matched, so search sees only what the page can show. + */ +function visibleMemorySchema( + sectionSchema: JsonSchema, + config: Record, +): JsonSchema { + const properties = sectionSchema.properties; + if (!properties) { + return sectionSchema; + } + const visible = new Set(memoryVisibleSchemaKeys(resolveMemoryBackend(config))); + return { + ...sectionSchema, + properties: Object.fromEntries( + Object.entries(properties).filter(([child]) => visible.has(child)), + ), + }; +} + +/** + * The Memory page splits `memory.*` across tabs and lifts `memory.backend` out + * of the editor into a curated row, so the bare section destination can land on + * a tab or an editor slice that omits the matched control. Only a hit one + * surface alone can show re-routes; a section-level match (key, label, + * description) is symmetric across slices and keeps the default destination. + */ +function memoryDestination(params: { + key: string; + schema: JsonSchema; + value: unknown; + hints: ConfigUiHints; + query: string; + editorHash: string; +}): { search: string; hash: string } { + const properties = params.schema.properties; + if (!properties) { + return { search: "", hash: params.editorHash }; + } + const sliceMatches = (keys: readonly string[]) => { + const sliced = Object.fromEntries( + Object.entries(properties).filter(([child]) => keys.includes(child)), + ); + return ( + Object.keys(sliced).length > 0 && + matchesConfigSectionSearch({ + key: params.key, + schema: { ...params.schema, properties: sliced }, + value: params.value, + hints: params.hints, + query: params.query, + textMatcher: settingsSearchTextMatches, + }) + ); + }; + const onlySliceMatches = (keys: readonly string[]) => + sliceMatches(keys) && + !sliceMatches(Object.keys(properties).filter((child) => !keys.includes(child))); + if (onlySliceMatches(MEMORY_SEARCH_TAB_SCHEMA_KEYS)) { + return { search: "&tab=search", hash: params.editorHash }; + } + // memoryVisibleSchemaKeys drops `backend` when no engine renders the curated + // row, so a match here always has the anchor on the page to scroll to. + if (onlySliceMatches(MEMORY_CURATED_SCHEMA_KEYS)) { + return { search: "", hash: `#${MEMORY_BACKEND_ANCHOR_ID}` }; + } + return { search: "", hash: params.editorHash }; +} + function routeForConfigSection(key: string): RouteId { if (MCP_SECTIONS.has(key)) { return "mcp"; } + if (MEMORY_SECTIONS.has(key)) { + return "memory"; + } if (COMMUNICATION_SECTIONS.has(key)) { return "communications"; } @@ -287,7 +371,10 @@ export function findSettingsSearchBlocks(params: { return matches; } const value = params.value ?? {}; - for (const [key, sectionSchema] of Object.entries(schema.properties)) { + for (const [key, rawSectionSchema] of Object.entries(schema.properties)) { + const routeId = routeForConfigSection(key); + const sectionSchema = + routeId === "memory" ? visibleMemorySchema(rawSectionSchema, value) : rawSectionSchema; const meta = SECTION_META[key]; const tierSplit = splitConfigSchemaByTier({ schema: sectionSchema, @@ -314,11 +401,23 @@ export function findSettingsSearchBlocks(params: { continue; } const encodedKey = encodeURIComponent(key); + const editorHash = `#config-section-${encodedKey}`; + const destination = + routeId === "memory" + ? memoryDestination({ + key, + schema: sectionSchema, + value: value[key], + hints: params.uiHints, + query: params.query, + editorHash, + }) + : { search: "", hash: editorHash }; matches.push({ - routeId: routeForConfigSection(key), + routeId, label: meta?.label ?? sectionSchema.title ?? key, - search: `?section=${encodedKey}${matchesAdvanced ? "&advanced=1" : ""}`, - hash: `#config-section-${encodedKey}`, + search: `?section=${encodedKey}${matchesAdvanced ? "&advanced=1" : ""}${destination.search}`, + hash: destination.hash, }); } return matches; diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index bc083ce7985e..996af422c791 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -754,6 +754,24 @@ margin-top: 12px; } +/* Memory */ +.memory-page { + display: grid; + gap: 14px; + margin-top: 12px; +} + +.memory-page__panel { + display: grid; + gap: 14px; +} + +.memory-page__link { + color: var(--accent); + font-size: 12.5px; + white-space: nowrap; +} + .mcp-command-card__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));