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));