From 02f2a66dffd3faa3872531049c07d0bd6442dde8 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 5 Apr 2026 15:21:09 -0700 Subject: [PATCH] memory-core: checkpoint mode-first dreaming refactor --- extensions/memory-core/index.ts | 2 - extensions/memory-core/src/dreaming.test.ts | 17 ++- src/memory-host-sdk/dreaming.test.ts | 6 +- src/memory-host-sdk/dreaming.ts | 147 ++++++++++++++++---- ui/src/ui/app-render.ts | 138 ++++++++---------- ui/src/ui/controllers/dreaming.test.ts | 31 +---- ui/src/ui/controllers/dreaming.ts | 63 ++++----- ui/src/ui/views/dreaming.test.ts | 46 +++--- ui/src/ui/views/dreaming.ts | 59 ++++---- 9 files changed, 273 insertions(+), 236 deletions(-) diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 7bc98e4aa89..f533d654bbc 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,7 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { registerMemoryCli } from "./src/cli.js"; import { registerDreamingCommand } from "./src/dreaming-command.js"; -import { registerMemoryDreamingPhases } from "./src/dreaming-phases.js"; import { registerShortTermPromotionDreaming } from "./src/dreaming.js"; import { buildMemoryFlushPlan, @@ -29,7 +28,6 @@ export default definePluginEntry({ register(api) { registerBuiltInMemoryEmbeddingProviders(api); registerShortTermPromotionDreaming(api); - registerMemoryDreamingPhases(api); registerDreamingCommand(api); api.registerMemoryPromptSection(buildPromptSection); api.registerMemoryFlushPlan(buildMemoryFlushPlan); diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index e28b47fe3ef..a34b0f72183 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -120,7 +120,7 @@ describe("short-term dreaming config", () => { cfg, }); expect(resolved).toEqual({ - enabled: true, + enabled: false, cron: constants.DEFAULT_DREAMING_CRON_EXPR, timezone: "America/Los_Angeles", limit: constants.DEFAULT_DREAMING_LIMIT, @@ -141,6 +141,7 @@ describe("short-term dreaming config", () => { const resolved = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", timezone: "UTC", verboseLogging: true, phases: { @@ -179,6 +180,7 @@ describe("short-term dreaming config", () => { const resolved = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", phases: { deep: { cron: "5 1 * * *", @@ -214,6 +216,7 @@ describe("short-term dreaming config", () => { const resolved = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", phases: { deep: { limit: " ", @@ -248,6 +251,7 @@ describe("short-term dreaming config", () => { const resolved = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", phases: { deep: { limit: 0, @@ -263,6 +267,7 @@ describe("short-term dreaming config", () => { const enabled = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", verboseLogging: true, }, }, @@ -270,6 +275,7 @@ describe("short-term dreaming config", () => { const disabled = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", verboseLogging: "false", }, }, @@ -283,6 +289,7 @@ describe("short-term dreaming config", () => { const resolved = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { + mode: "core", phases: { deep: { minScore: -0.2, @@ -305,15 +312,11 @@ describe("short-term dreaming config", () => { expect(resolved.maxAgeDays).toBe(30); }); - it("keeps deep sleep disabled when the phase is off", () => { + it("keeps deep sleep disabled when mode is off", () => { const resolved = resolveShortTermPromotionDreamingConfig({ pluginConfig: { dreaming: { - phases: { - deep: { - enabled: false, - }, - }, + mode: "off", }, }, }); diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts index fe447b90f45..f2ba52fcb4a 100644 --- a/src/memory-host-sdk/dreaming.test.ts +++ b/src/memory-host-sdk/dreaming.test.ts @@ -54,6 +54,7 @@ describe("memory dreaming host helpers", () => { }, }); + expect(resolved.mode).toBe("core"); expect(resolved.enabled).toBe(true); expect(resolved.timezone).toBe("Europe/London"); expect(resolved.storage).toEqual({ @@ -85,12 +86,13 @@ describe("memory dreaming host helpers", () => { cfg, }); - expect(resolved.enabled).toBe(true); + expect(resolved.mode).toBe("off"); + expect(resolved.enabled).toBe(false); expect(resolved.timezone).toBe("America/Los_Angeles"); expect(resolved.phases.deep).toMatchObject({ cron: "0 3 * * *", limit: 10, - minScore: 0.8, + minScore: 0.75, recencyHalfLifeDays: 14, maxAgeDays: 30, }); diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index 263bf8ec175..ff5bdf6a635 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -3,11 +3,13 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import type { OpenClawConfig } from "../config/config.js"; -export const DEFAULT_MEMORY_DREAMING_ENABLED = true; +export const DEFAULT_MEMORY_DREAMING_ENABLED = false; export const DEFAULT_MEMORY_DREAMING_TIMEZONE = undefined; export const DEFAULT_MEMORY_DREAMING_VERBOSE_LOGGING = false; export const DEFAULT_MEMORY_DREAMING_STORAGE_MODE = "inline"; export const DEFAULT_MEMORY_DREAMING_SEPARATE_REPORTS = false; +export const DEFAULT_MEMORY_DREAMING_MODE = "off"; +export const DEFAULT_MEMORY_DREAMING_PRESET = "core"; export const DEFAULT_MEMORY_LIGHT_DREAMING_CRON_EXPR = "0 */6 * * *"; export const DEFAULT_MEMORY_LIGHT_DREAMING_LOOKBACK_DAYS = 2; @@ -16,9 +18,9 @@ export const DEFAULT_MEMORY_LIGHT_DREAMING_DEDUPE_SIMILARITY = 0.9; export const DEFAULT_MEMORY_DEEP_DREAMING_CRON_EXPR = "0 3 * * *"; export const DEFAULT_MEMORY_DEEP_DREAMING_LIMIT = 10; -export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE = 0.8; +export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE = 0.75; export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT = 3; -export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES = 3; +export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES = 2; export const DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS = 14; export const DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS = 30; @@ -42,6 +44,8 @@ export type MemoryDreamingSpeed = "fast" | "balanced" | "slow"; export type MemoryDreamingThinking = "low" | "medium" | "high"; export type MemoryDreamingBudget = "cheap" | "medium" | "expensive"; export type MemoryDreamingStorageMode = "inline" | "separate" | "both"; +export type MemoryDreamingPreset = "core" | "deep" | "rem"; +export type MemoryDreamingMode = MemoryDreamingPreset | "off"; export type MemoryLightDreamingSource = "daily" | "sessions" | "recall"; export type MemoryDeepDreamingSource = "daily" | "memory" | "sessions" | "logs" | "recall"; @@ -108,6 +112,7 @@ export type MemoryRemDreamingConfig = { export type MemoryDreamingPhaseName = "light" | "deep" | "rem"; export type MemoryDreamingConfig = { + mode: MemoryDreamingMode; enabled: boolean; timezone?: string; verboseLogging: boolean; @@ -141,6 +146,47 @@ const DEFAULT_MEMORY_DEEP_DREAMING_SOURCES: MemoryDeepDreamingSource[] = [ ]; const DEFAULT_MEMORY_REM_DREAMING_SOURCES: MemoryRemDreamingSource[] = ["memory", "daily", "deep"]; +const MEMORY_DREAMING_PRESET_DEFAULTS: Record< + MemoryDreamingPreset, + { + cron: string; + limit: number; + minScore: number; + minRecallCount: number; + minUniqueQueries: number; + recencyHalfLifeDays: number; + maxAgeDays: number; + } +> = { + core: { + cron: DEFAULT_MEMORY_DEEP_DREAMING_CRON_EXPR, + limit: DEFAULT_MEMORY_DEEP_DREAMING_LIMIT, + minScore: DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE, + minRecallCount: DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT, + minUniqueQueries: DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES, + recencyHalfLifeDays: DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS, + maxAgeDays: DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS, + }, + deep: { + cron: "0 */12 * * *", + limit: DEFAULT_MEMORY_DEEP_DREAMING_LIMIT, + minScore: 0.8, + minRecallCount: 3, + minUniqueQueries: 3, + recencyHalfLifeDays: DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS, + maxAgeDays: DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS, + }, + rem: { + cron: "0 */6 * * *", + limit: DEFAULT_MEMORY_DEEP_DREAMING_LIMIT, + minScore: 0.85, + minRecallCount: 4, + minUniqueQueries: 3, + recencyHalfLifeDays: DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS, + maxAgeDays: DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS, + }, +}; + function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -242,6 +288,23 @@ function normalizeStringArray( return normalized.length > 0 ? normalized : [...fallback]; } +function parseMemoryDreamingMode(value: unknown): MemoryDreamingMode | undefined { + const normalized = normalizeTrimmedString(value)?.toLowerCase(); + if ( + normalized === "off" || + normalized === "core" || + normalized === "deep" || + normalized === "rem" + ) { + return normalized; + } + return undefined; +} + +export function normalizeMemoryDreamingMode(value: unknown): MemoryDreamingMode { + return parseMemoryDreamingMode(value) ?? DEFAULT_MEMORY_DREAMING_MODE; +} + function normalizeStorageMode(value: unknown): MemoryDreamingStorageMode { const normalized = normalizeTrimmedString(value)?.toLowerCase(); if (normalized === "inline" || normalized === "separate" || normalized === "both") { @@ -328,6 +391,13 @@ export function resolveMemoryDreamingConfig(params: { cfg?: OpenClawConfig; }): MemoryDreamingConfig { const dreaming = asRecord(params.pluginConfig?.dreaming); + const explicitMode = parseMemoryDreamingMode(dreaming?.mode); + const legacyEnabled = normalizeBoolean(dreaming?.enabled, DEFAULT_MEMORY_DREAMING_ENABLED); + const mode: MemoryDreamingMode = + explicitMode ?? (legacyEnabled ? DEFAULT_MEMORY_DREAMING_PRESET : DEFAULT_MEMORY_DREAMING_MODE); + const enabled = mode !== "off"; + const preset: MemoryDreamingPreset = mode === "off" ? DEFAULT_MEMORY_DREAMING_PRESET : mode; + const presetDefaults = MEMORY_DREAMING_PRESET_DEFAULTS[preset]; const timezone = normalizeTrimmedString(dreaming?.timezone) ?? normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone) ?? @@ -335,6 +405,7 @@ export function resolveMemoryDreamingConfig(params: { const storage = asRecord(dreaming?.storage); const execution = asRecord(dreaming?.execution); const phases = asRecord(dreaming?.phases); + const frequencyAlias = normalizeTrimmedString(dreaming?.frequency); const defaultExecution = resolveExecutionConfig(execution?.defaults, { speed: DEFAULT_MEMORY_DREAMING_SPEED, @@ -346,10 +417,39 @@ export function resolveMemoryDreamingConfig(params: { const deep = asRecord(phases?.deep); const rem = asRecord(phases?.rem); const deepRecovery = asRecord(deep?.recovery); - const maxAgeDays = normalizeOptionalPositiveInt(deep?.maxAgeDays); + const maxAgeDays = + normalizeOptionalPositiveInt(dreaming?.maxAgeDays) ?? + normalizeOptionalPositiveInt(deep?.maxAgeDays) ?? + presetDefaults.maxAgeDays; + const deepCron = + normalizeTrimmedString(dreaming?.cron) ?? + frequencyAlias ?? + normalizeTrimmedString(deep?.cron) ?? + presetDefaults.cron; + const deepLimit = normalizeNonNegativeInt( + dreaming?.limit, + normalizeNonNegativeInt(deep?.limit, presetDefaults.limit), + ); + const deepMinScore = normalizeScore( + dreaming?.minScore, + normalizeScore(deep?.minScore, presetDefaults.minScore), + ); + const deepMinRecallCount = normalizeNonNegativeInt( + dreaming?.minRecallCount, + normalizeNonNegativeInt(deep?.minRecallCount, presetDefaults.minRecallCount), + ); + const deepMinUniqueQueries = normalizeNonNegativeInt( + dreaming?.minUniqueQueries, + normalizeNonNegativeInt(deep?.minUniqueQueries, presetDefaults.minUniqueQueries), + ); + const deepRecencyHalfLifeDays = normalizeNonNegativeInt( + dreaming?.recencyHalfLifeDays, + normalizeNonNegativeInt(deep?.recencyHalfLifeDays, presetDefaults.recencyHalfLifeDays), + ); return { - enabled: normalizeBoolean(dreaming?.enabled, DEFAULT_MEMORY_DREAMING_ENABLED), + mode, + enabled, ...(timezone ? { timezone } : {}), verboseLogging: normalizeBoolean( dreaming?.verboseLogging, @@ -367,7 +467,9 @@ export function resolveMemoryDreamingConfig(params: { }, phases: { light: { - enabled: normalizeBoolean(light?.enabled, true), + // Phased dreaming was experimental and too noisy. Keep light sleep off + // unless we intentionally bring it back behind a separate effort. + enabled: false, cron: normalizeTrimmedString(light?.cron) ?? DEFAULT_MEMORY_LIGHT_DREAMING_CRON_EXPR, lookbackDays: normalizeNonNegativeInt( light?.lookbackDays, @@ -391,27 +493,14 @@ export function resolveMemoryDreamingConfig(params: { }), }, deep: { - enabled: normalizeBoolean(deep?.enabled, true), - cron: normalizeTrimmedString(deep?.cron) ?? DEFAULT_MEMORY_DEEP_DREAMING_CRON_EXPR, - limit: normalizeNonNegativeInt(deep?.limit, DEFAULT_MEMORY_DEEP_DREAMING_LIMIT), - minScore: normalizeScore(deep?.minScore, DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE), - minRecallCount: normalizeNonNegativeInt( - deep?.minRecallCount, - DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT, - ), - minUniqueQueries: normalizeNonNegativeInt( - deep?.minUniqueQueries, - DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES, - ), - recencyHalfLifeDays: normalizeNonNegativeInt( - deep?.recencyHalfLifeDays, - DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS, - ), - ...(typeof maxAgeDays === "number" - ? { maxAgeDays } - : typeof DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS === "number" - ? { maxAgeDays: DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS } - : {}), + enabled, + cron: deepCron, + limit: deepLimit, + minScore: deepMinScore, + minRecallCount: deepMinRecallCount, + minUniqueQueries: deepMinUniqueQueries, + recencyHalfLifeDays: deepRecencyHalfLifeDays, + ...(typeof maxAgeDays === "number" ? { maxAgeDays } : {}), sources: normalizeStringArray( deep?.sources, ["daily", "memory", "sessions", "logs", "recall"] as const, @@ -451,7 +540,9 @@ export function resolveMemoryDreamingConfig(params: { }), }, rem: { - enabled: normalizeBoolean(rem?.enabled, true), + // REM reflections are currently disabled by default to keep dreaming + // predictable and focused on durable promotion modes. + enabled: false, cron: normalizeTrimmedString(rem?.cron) ?? DEFAULT_MEMORY_REM_DREAMING_CRON_EXPR, lookbackDays: normalizeNonNegativeInt( rem?.lookbackDays, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8d43b11bbfa..0cf76ecf9fb 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -66,9 +66,8 @@ import { } from "./controllers/devices.ts"; import { loadDreamingStatus, - updateDreamingEnabled, - updateDreamingPhaseEnabled, - type DreamingPhaseId, + updateDreamingMode, + type DreamingMode, } from "./controllers/dreaming.ts"; import { loadExecApprovals, @@ -150,42 +149,34 @@ const lazyNodes = createLazy(() => import("./views/nodes.ts")); const lazySessions = createLazy(() => import("./views/sessions.ts")); const lazySkills = createLazy(() => import("./views/skills.ts")); const lazyDreamingView = createLazy(() => import("./views/dreaming.ts")); -const DREAMING_PHASE_OPTIONS: Array<{ id: DreamingPhaseId; label: string; detail: string }> = [ - { id: "light", label: "Light", detail: "sort and stage the day" }, - { id: "deep", label: "Deep", detail: "promote durable memory" }, - { id: "rem", label: "REM", detail: "surface themes and reflections" }, +const DREAMING_MODE_OPTIONS: Array<{ id: DreamingMode; label: string; detail: string }> = [ + { id: "off", label: "Off", detail: "no automatic promotion" }, + { id: "core", label: "Core", detail: "nightly durable consolidation" }, + { id: "rem", label: "REM", detail: "every 6 hours, stricter" }, + { id: "deep", label: "Deep", detail: "every 12 hours, conservative" }, ]; function resolveConfiguredDreaming(configValue: Record | null): { - enabled: boolean; - phases: Record; + mode: DreamingMode; } { + const fallback: DreamingMode = "off"; if (!configValue) { - return { - enabled: true, - phases: { - light: true, - deep: true, - rem: true, - }, - }; + return { mode: fallback }; } const plugins = configValue.plugins as Record | undefined; const entries = plugins?.entries as Record | undefined; const memoryCore = entries?.["memory-core"] as Record | undefined; const config = memoryCore?.config as Record | undefined; const dreaming = config?.dreaming as Record | undefined; - const phases = dreaming?.phases as Record | undefined; - const light = phases?.light as Record | undefined; - const deep = phases?.deep as Record | undefined; - const rem = phases?.rem as Record | undefined; + const modeRaw = typeof dreaming?.mode === "string" ? dreaming.mode.trim().toLowerCase() : ""; + if (modeRaw === "off" || modeRaw === "core" || modeRaw === "rem" || modeRaw === "deep") { + return { mode: modeRaw }; + } + if (typeof dreaming?.enabled === "boolean") { + return { mode: dreaming.enabled ? "core" : "off" }; + } return { - enabled: typeof dreaming?.enabled === "boolean" ? dreaming.enabled : true, - phases: { - light: typeof light?.enabled === "boolean" ? light.enabled : true, - deep: typeof deep?.enabled === "boolean" ? deep.enabled : true, - rem: typeof rem?.enabled === "boolean" ? rem.enabled : true, - }, + mode: fallback, }; } @@ -200,12 +191,20 @@ function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null { } function resolveDreamingNextCycle( - status: { phases: Record } | null, + status: { + phases: { + deep?: { enabled: boolean; nextRunAtMs?: number }; + light?: { enabled: boolean; nextRunAtMs?: number }; + rem?: { enabled: boolean; nextRunAtMs?: number }; + }; + } | null, ): string | null { if (!status) { return null; } - const nextRunAtMs = Object.values(status.phases) + const phases = [status.phases.deep, status.phases.light, status.phases.rem]; + const nextRunAtMs = phases + .filter((phase): phase is { enabled: boolean; nextRunAtMs?: number } => Boolean(phase)) .filter((phase) => phase.enabled && typeof phase.nextRunAtMs === "number") .map((phase) => phase.nextRunAtMs as number) .toSorted((a, b) => a - b)[0]; @@ -398,34 +397,17 @@ export function renderApp(state: AppViewState) { const configValue = state.configForm ?? (state.configSnapshot?.config as Record | null); const configuredDreaming = resolveConfiguredDreaming(configValue); - const dreamingOn = state.dreamingStatus?.enabled ?? configuredDreaming.enabled; + const dreamingMode = configuredDreaming.mode; + const dreamingOn = dreamingMode !== "off"; const dreamingNextCycle = resolveDreamingNextCycle(state.dreamingStatus); const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving; const refreshDreamingStatus = () => loadDreamingStatus(state); - const applyDreamingEnabled = (enabled: boolean) => { - if (state.dreamingModeSaving || dreamingOn === enabled) { + const applyDreamingMode = (mode: DreamingMode) => { + if (state.dreamingModeSaving || dreamingMode === mode) { return; } void (async () => { - const updated = await updateDreamingEnabled(state, enabled); - if (!updated) { - return; - } - await loadConfig(state); - await loadDreamingStatus(state); - })(); - }; - const applyDreamingPhaseEnabled = (phase: DreamingPhaseId, enabled: boolean) => { - if (state.dreamingModeSaving) { - return; - } - const currentEnabled = - state.dreamingStatus?.phases[phase].enabled ?? configuredDreaming.phases[phase]; - if (currentEnabled === enabled) { - return; - } - void (async () => { - const updated = await updateDreamingPhaseEnabled(state, phase, enabled); + const updated = await updateDreamingMode(state, mode); if (!updated) { return; } @@ -740,24 +722,28 @@ export function renderApp(state: AppViewState) {
- + ${DREAMING_MODE_OPTIONS.map( + (option) => html` + + `, + )}
` @@ -2148,29 +2134,19 @@ export function renderApp(state: AppViewState) { ? lazyRender(lazyDreamingView, (m) => m.renderDreaming({ active: dreamingOn, + mode: dreamingMode, shortTermCount: state.dreamingStatus?.shortTermCount ?? 0, longTermCount: state.dreamingStatus?.promotedTotal ?? 0, promotedCount: state.dreamingStatus?.promotedToday ?? 0, dreamingOf: null, nextCycle: dreamingNextCycle, timezone: state.dreamingStatus?.timezone ?? null, - phases: DREAMING_PHASE_OPTIONS.map((phase) => ({ - ...phase, - enabled: - state.dreamingStatus?.phases[phase.id].enabled ?? - configuredDreaming.phases[phase.id], - nextCycle: formatDreamNextCycle( - state.dreamingStatus?.phases[phase.id].nextRunAtMs, - ), - managedCronPresent: - state.dreamingStatus?.phases[phase.id].managedCronPresent ?? false, - })), + modes: DREAMING_MODE_OPTIONS, statusLoading: state.dreamingStatusLoading, statusError: state.dreamingStatusError, modeSaving: state.dreamingModeSaving, onRefresh: refreshDreamingStatus, - onToggleEnabled: applyDreamingEnabled, - onTogglePhase: applyDreamingPhaseEnabled, + onSelectMode: applyDreamingMode, }), ) : nothing} diff --git a/ui/src/ui/controllers/dreaming.test.ts b/ui/src/ui/controllers/dreaming.test.ts index bb2eaaed75d..efe641ec940 100644 --- a/ui/src/ui/controllers/dreaming.test.ts +++ b/ui/src/ui/controllers/dreaming.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { - loadDreamingStatus, - updateDreamingEnabled, - updateDreamingPhaseEnabled, - type DreamingState, -} from "./dreaming.ts"; +import { loadDreamingStatus, updateDreamingMode, type DreamingState } from "./dreaming.ts"; function createState(): { state: DreamingState; request: ReturnType } { const request = vi.fn(); @@ -29,6 +24,7 @@ describe("dreaming controller", () => { const { state, request } = createState(); request.mockResolvedValue({ dreaming: { + mode: "core", enabled: true, timezone: "America/Los_Angeles", verboseLogging: false, @@ -76,6 +72,7 @@ describe("dreaming controller", () => { expect(request).toHaveBeenCalledWith("doctor.memory.status", {}); expect(state.dreamingStatus).toEqual( expect.objectContaining({ + mode: "core", enabled: true, shortTermCount: 8, promotedToday: 2, @@ -91,17 +88,18 @@ describe("dreaming controller", () => { expect(state.dreamingStatusError).toBeNull(); }); - it("patches config to update global dreaming enablement", async () => { + it("patches config to update dreaming mode", async () => { const { state, request } = createState(); request.mockResolvedValue({ ok: true }); - const ok = await updateDreamingEnabled(state, false); + const ok = await updateDreamingMode(state, "deep"); expect(ok).toBe(true); expect(request).toHaveBeenCalledWith( "config.patch", expect.objectContaining({ baseHash: "hash-1", + raw: expect.stringContaining('"mode":"deep"'), sessionKey: "main", }), ); @@ -109,26 +107,11 @@ describe("dreaming controller", () => { expect(state.dreamingStatusError).toBeNull(); }); - it("patches config to update phase enablement", async () => { - const { state, request } = createState(); - request.mockResolvedValue({ ok: true }); - - const ok = await updateDreamingPhaseEnabled(state, "rem", false); - - expect(ok).toBe(true); - expect(request).toHaveBeenCalledWith( - "config.patch", - expect.objectContaining({ - raw: expect.stringContaining('"rem":{"enabled":false}'), - }), - ); - }); - it("fails gracefully when config hash is missing", async () => { const { state, request } = createState(); state.configSnapshot = {}; - const ok = await updateDreamingEnabled(state, true); + const ok = await updateDreamingMode(state, "core"); expect(ok).toBe(false); expect(request).not.toHaveBeenCalled(); diff --git a/ui/src/ui/controllers/dreaming.ts b/ui/src/ui/controllers/dreaming.ts index 0bfac1bb427..de2f397a764 100644 --- a/ui/src/ui/controllers/dreaming.ts +++ b/ui/src/ui/controllers/dreaming.ts @@ -1,7 +1,7 @@ import type { GatewayBrowserClient } from "../gateway.ts"; import type { ConfigSnapshot } from "../types.ts"; -export type DreamingPhaseId = "light" | "deep" | "rem"; +export type DreamingMode = "off" | "core" | "rem" | "deep"; type DreamingPhaseStatusBase = { enabled: boolean; @@ -31,6 +31,7 @@ type RemDreamingStatus = DreamingPhaseStatusBase & { }; export type DreamingStatus = { + mode: DreamingMode; enabled: boolean; timezone?: string; verboseLogging: boolean; @@ -105,6 +106,19 @@ function normalizeStorageMode(value: unknown): DreamingStatus["storageMode"] { return "inline"; } +function normalizeDreamingMode(value: unknown): DreamingMode | undefined { + const normalized = normalizeTrimmedString(value)?.toLowerCase(); + if ( + normalized === "off" || + normalized === "core" || + normalized === "rem" || + normalized === "deep" + ) { + return normalized; + } + return undefined; +} + function normalizeNextRun(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -132,9 +146,13 @@ function normalizeDreamingStatus(raw: unknown): DreamingStatus | null { const timezone = normalizeTrimmedString(record.timezone); const storePath = normalizeTrimmedString(record.storePath); const storeError = normalizeTrimmedString(record.storeError); + const explicitMode = normalizeDreamingMode(record.mode); + const enabled = normalizeBoolean(record.enabled, false); + const mode = explicitMode ?? (enabled ? "core" : "off"); return { - enabled: normalizeBoolean(record.enabled, false), + mode, + enabled, ...(timezone ? { timezone } : {}), verboseLogging: normalizeBoolean(record.verboseLogging, false), storageMode: normalizeStorageMode(record.storageMode), @@ -230,32 +248,12 @@ export async function updateDreamingEnabled( state: DreamingState, enabled: boolean, ): Promise { - const ok = await writeDreamingPatch(state, { - plugins: { - entries: { - "memory-core": { - config: { - dreaming: { - enabled, - }, - }, - }, - }, - }, - }); - if (ok && state.dreamingStatus) { - state.dreamingStatus = { - ...state.dreamingStatus, - enabled, - }; - } - return ok; + return updateDreamingMode(state, enabled ? "core" : "off"); } -export async function updateDreamingPhaseEnabled( +export async function updateDreamingMode( state: DreamingState, - phase: DreamingPhaseId, - enabled: boolean, + mode: DreamingMode, ): Promise { const ok = await writeDreamingPatch(state, { plugins: { @@ -263,11 +261,7 @@ export async function updateDreamingPhaseEnabled( "memory-core": { config: { dreaming: { - phases: { - [phase]: { - enabled, - }, - }, + mode, }, }, }, @@ -277,13 +271,8 @@ export async function updateDreamingPhaseEnabled( if (ok && state.dreamingStatus) { state.dreamingStatus = { ...state.dreamingStatus, - phases: { - ...state.dreamingStatus.phases, - [phase]: { - ...state.dreamingStatus.phases[phase], - enabled, - }, - }, + mode, + enabled: mode !== "off", }; } return ok; diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index da067c5ea8c..e3528bcb84d 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -7,44 +7,40 @@ import { renderDreaming, type DreamingProps } from "./dreaming.ts"; function buildProps(overrides?: Partial): DreamingProps { return { active: true, + mode: "core", shortTermCount: 47, longTermCount: 182, promotedCount: 12, dreamingOf: null, nextCycle: "4:00 AM", timezone: "America/Los_Angeles", - phases: [ + modes: [ { - id: "light", - label: "Light", - detail: "sort and stage the day", - enabled: true, - nextCycle: "1:00 AM", - managedCronPresent: true, + id: "off", + label: "Off", + detail: "no automatic promotion", + }, + { + id: "core", + label: "Core", + detail: "nightly durable consolidation", }, { id: "deep", label: "Deep", - detail: "promote durable memory", - enabled: true, - nextCycle: "3:00 AM", - managedCronPresent: true, + detail: "every 12 hours, conservative", }, { id: "rem", label: "REM", - detail: "surface themes and reflections", - enabled: false, - nextCycle: null, - managedCronPresent: false, + detail: "every 6 hours, stricter", }, ], statusLoading: false, statusError: null, modeSaving: false, onRefresh: () => {}, - onToggleEnabled: () => {}, - onTogglePhase: () => {}, + onSelectMode: () => {}, ...overrides, }; } @@ -107,13 +103,13 @@ describe("dreaming view", () => { it("shows active status label when active", () => { const container = renderInto(buildProps({ active: true })); const label = container.querySelector(".dreams__status-label"); - expect(label?.textContent).toBe("Dreaming Active"); + expect(label?.textContent).toContain("Dreaming Active"); }); it("shows idle status label when inactive", () => { const container = renderInto(buildProps({ active: false })); const label = container.querySelector(".dreams__status-label"); - expect(label?.textContent).toBe("Dreaming Idle"); + expect(label?.textContent).toContain("Dreaming Idle"); }); it("applies idle class when not active", () => { @@ -127,10 +123,10 @@ describe("dreaming view", () => { expect(detail?.textContent).toContain("4:00 AM"); }); - it("renders phase controls", () => { + it("renders mode controls", () => { const container = renderInto(buildProps()); expect(container.querySelector(".dreams__controls")).not.toBeNull(); - expect(container.querySelectorAll(".dreams__phase").length).toBe(3); + expect(container.querySelectorAll(".dreams__phase").length).toBe(4); }); it("renders control error when present", () => { @@ -140,12 +136,12 @@ describe("dreaming view", () => { ); }); - it("wires phase toggle callbacks", () => { - const onTogglePhase = vi.fn(); - const container = renderInto(buildProps({ onTogglePhase })); + it("wires mode selection callbacks", () => { + const onSelectMode = vi.fn(); + const container = renderInto(buildProps({ onSelectMode })); container.querySelector(".dreams__phase .btn")?.click(); - expect(onTogglePhase).toHaveBeenCalled(); + expect(onSelectMode).toHaveBeenCalled(); }); }); diff --git a/ui/src/ui/views/dreaming.ts b/ui/src/ui/views/dreaming.ts index 3b39b161fec..c868d0791d2 100644 --- a/ui/src/ui/views/dreaming.ts +++ b/ui/src/ui/views/dreaming.ts @@ -1,28 +1,25 @@ import { html, nothing } from "lit"; -import type { DreamingPhaseId } from "../controllers/dreaming.ts"; +import type { DreamingMode } from "../controllers/dreaming.ts"; export type DreamingProps = { active: boolean; + mode: DreamingMode; shortTermCount: number; longTermCount: number; promotedCount: number; dreamingOf: string | null; nextCycle: string | null; timezone: string | null; - phases: Array<{ - id: DreamingPhaseId; + modes: Array<{ + id: DreamingMode; label: string; detail: string; - enabled: boolean; - nextCycle: string | null; - managedCronPresent: boolean; }>; statusLoading: boolean; statusError: string | null; modeSaving: boolean; onRefresh: () => void; - onToggleEnabled: (enabled: boolean) => void; - onTogglePhase: (phase: DreamingPhaseId, enabled: boolean) => void; + onSelectMode: (mode: DreamingMode) => void; }; const DREAM_PHRASES = [ @@ -163,13 +160,13 @@ export function renderDreaming(props: DreamingProps) {
${props.active ? "Dreaming Active" : "Dreaming Idle"}${props.active ? "Dreaming Active" : "Dreaming Idle"} · ${props.mode.toUpperCase()}
${props.promotedCount} promoted - ${props.nextCycle ? html`· next phase ${props.nextCycle}` : nothing} + ${props.nextCycle ? html`· next run ${props.nextCycle}` : nothing} ${props.timezone ? html`· ${props.timezone}` : nothing}
@@ -201,9 +198,9 @@ export function renderDreaming(props: DreamingProps) {
-
Dreaming Phases
+
Dreaming Modes
- Light sleep sorts, deep sleep keeps, REM reflects. + Pick a cadence and threshold profile for durable promotion.
@@ -214,36 +211,38 @@ export function renderDreaming(props: DreamingProps) { > ${props.statusLoading ? "Refreshing…" : "Refresh"} -
- ${props.phases.map( - (phase) => html` -
+ ${props.modes.map( + (mode) => html` +
-
${phase.label}
-
${phase.detail}
+
${mode.label}
+
${mode.detail}
- ${phase.enabled ? "scheduled" : "off"} - ${phase.nextCycle ? `next ${phase.nextCycle}` : "no next run"} - ${phase.managedCronPresent ? "managed cron" : "cron missing"} + ${props.mode === mode.id ? "selected" : "available"} + + ${mode.id === "off" + ? "no background runs" + : mode.id === "core" + ? "nightly cadence" + : mode.id === "deep" + ? "every 12 hours" + : "every 6 hours"} +
`,