memory-core: checkpoint mode-first dreaming refactor

This commit is contained in:
Vignesh Natarajan 2026-04-05 15:21:09 -07:00
parent 5a42355d54
commit 02f2a66dff
No known key found for this signature in database
GPG Key ID: C5E014CC92E2A144
9 changed files with 273 additions and 236 deletions

View File

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

View File

@ -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",
},
},
});

View File

@ -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,
});

View File

@ -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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@ -242,6 +288,23 @@ function normalizeStringArray<T extends string>(
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,

View File

@ -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<string, unknown> | null): {
enabled: boolean;
phases: Record<DreamingPhaseId, boolean>;
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<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const memoryCore = entries?.["memory-core"] as Record<string, unknown> | undefined;
const config = memoryCore?.config as Record<string, unknown> | undefined;
const dreaming = config?.dreaming as Record<string, unknown> | undefined;
const phases = dreaming?.phases as Record<string, unknown> | undefined;
const light = phases?.light as Record<string, unknown> | undefined;
const deep = phases?.deep as Record<string, unknown> | undefined;
const rem = phases?.rem as Record<string, unknown> | 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<DreamingPhaseId, { enabled: boolean; nextRunAtMs?: number }> } | 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<string, unknown> | 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) {
<div
class="dreaming-header-controls__modes"
role="group"
aria-label="Dreaming controls"
aria-label="Dreaming mode"
>
<button
class="dreaming-header-controls__mode ${dreamingOn
? "dreaming-header-controls__mode--active"
: ""}"
?disabled=${dreamingLoading}
title=${dreamingOn ? "Dreaming is enabled." : "Dreaming is disabled."}
aria-label=${dreamingOn ? "Disable dreaming" : "Enable dreaming"}
@click=${() => applyDreamingEnabled(!dreamingOn)}
>
<span class="dreaming-header-controls__mode-label"
>${dreamingOn ? "Dreaming On" : "Dreaming Off"}</span
>
<span class="dreaming-header-controls__mode-detail"
>${dreamingOn ? "all phases may run" : "no phases will run"}</span
>
</button>
${DREAMING_MODE_OPTIONS.map(
(option) => html`
<button
class="dreaming-header-controls__mode ${dreamingMode === option.id
? "dreaming-header-controls__mode--active"
: ""}"
?disabled=${dreamingLoading}
title=${option.detail}
aria-label=${`Set dreaming mode to ${option.label}`}
@click=${() => applyDreamingMode(option.id)}
>
<span class="dreaming-header-controls__mode-label"
>${option.label}</span
>
<span class="dreaming-header-controls__mode-detail"
>${option.detail}</span
>
</button>
`,
)}
</div>
</div>
`
@ -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}

View File

@ -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<typeof vi.fn> } {
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();

View File

@ -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<boolean> {
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<boolean> {
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;

View File

@ -7,44 +7,40 @@ import { renderDreaming, type DreamingProps } from "./dreaming.ts";
function buildProps(overrides?: Partial<DreamingProps>): 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<HTMLButtonElement>(".dreams__phase .btn")?.click();
expect(onTogglePhase).toHaveBeenCalled();
expect(onSelectMode).toHaveBeenCalled();
});
});

View File

@ -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) {
<div class="dreams__status">
<span class="dreams__status-label"
>${props.active ? "Dreaming Active" : "Dreaming Idle"}</span
>${props.active ? "Dreaming Active" : "Dreaming Idle"} · ${props.mode.toUpperCase()}</span
>
<div class="dreams__status-detail">
<div class="dreams__status-dot"></div>
<span>
${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}
</span>
</div>
@ -201,9 +198,9 @@ export function renderDreaming(props: DreamingProps) {
<div class="dreams__controls">
<div class="dreams__controls-head">
<div>
<div class="dreams__controls-title">Dreaming Phases</div>
<div class="dreams__controls-title">Dreaming Modes</div>
<div class="dreams__controls-subtitle">
Light sleep sorts, deep sleep keeps, REM reflects.
Pick a cadence and threshold profile for durable promotion.
</div>
</div>
<div class="dreams__controls-actions">
@ -214,36 +211,38 @@ export function renderDreaming(props: DreamingProps) {
>
${props.statusLoading ? "Refreshing…" : "Refresh"}
</button>
<button
class="btn btn--sm ${props.active ? "btn--subtle" : ""}"
?disabled=${props.modeSaving}
@click=${() => props.onToggleEnabled(!props.active)}
>
${props.active ? "Disable Dreaming" : "Enable Dreaming"}
</button>
</div>
</div>
<div class="dreams__phase-grid">
${props.phases.map(
(phase) => html`
<article class="dreams__phase ${phase.enabled ? "dreams__phase--active" : ""}">
${props.modes.map(
(mode) => html`
<article
class="dreams__phase ${props.mode === mode.id ? "dreams__phase--active" : ""}"
>
<div class="dreams__phase-top">
<div>
<div class="dreams__phase-label">${phase.label}</div>
<div class="dreams__phase-detail">${phase.detail}</div>
<div class="dreams__phase-label">${mode.label}</div>
<div class="dreams__phase-detail">${mode.detail}</div>
</div>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || !props.active}
@click=${() => props.onTogglePhase(phase.id, !phase.enabled)}
?disabled=${props.modeSaving || props.mode === mode.id}
@click=${() => props.onSelectMode(mode.id)}
>
${phase.enabled ? "Pause" : "Enable"}
${props.mode === mode.id ? "Active" : "Set"}
</button>
</div>
<div class="dreams__phase-meta">
<span>${phase.enabled ? "scheduled" : "off"}</span>
<span>${phase.nextCycle ? `next ${phase.nextCycle}` : "no next run"}</span>
<span>${phase.managedCronPresent ? "managed cron" : "cron missing"}</span>
<span>${props.mode === mode.id ? "selected" : "available"}</span>
<span>
${mode.id === "off"
? "no background runs"
: mode.id === "core"
? "nightly cadence"
: mode.id === "deep"
? "every 12 hours"
: "every 6 hours"}
</span>
</div>
</article>
`,