Dreaming: move setup controls to header and tighten status plumbing

This commit is contained in:
Vignesh Natarajan
2026-04-03 21:57:44 -07:00
parent a5f66b5c48
commit f8c4777515
13 changed files with 1051 additions and 25 deletions

View File

@@ -165,20 +165,16 @@ function loadBundledChannelContractSurfaceEntries(): Array<{
if (manifest.origin !== "bundled" || manifest.channels.length === 0) {
continue;
}
const modulePaths = resolveSourceFirstContractSurfaceModulePaths({
const modulePath = resolveSourceFirstContractSurfaceModulePaths({
rootDir: manifest.rootDir,
});
if (modulePaths.length === 0) {
})[0];
if (!modulePath) {
continue;
}
try {
const surface = Object.assign(
{},
...modulePaths.map((modulePath) => loadModule(modulePath)(modulePath) as object),
);
surfaces.push({
pluginId: manifest.id,
surface,
surface: loadModule(modulePath)(modulePath),
});
} catch {
continue;

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
@@ -19,12 +22,24 @@ vi.mock("../../plugins/memory-runtime.js", () => ({
import { doctorHandlers } from "./doctor.js";
const invokeDoctorMemoryStatus = async (respond: ReturnType<typeof vi.fn>) => {
const invokeDoctorMemoryStatus = async (
respond: ReturnType<typeof vi.fn>,
context?: { cron?: { list?: ReturnType<typeof vi.fn> } },
) => {
const cronList =
context?.cron?.list ??
vi.fn(async () => {
return [];
});
await doctorHandlers["doctor.memory.status"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
context: {
cron: {
list: cronList,
},
} as never,
client: null,
isWebchatConnect: () => false,
});
@@ -33,13 +48,13 @@ const invokeDoctorMemoryStatus = async (respond: ReturnType<typeof vi.fn>) => {
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
expect(respond).toHaveBeenCalledWith(
true,
{
expect.objectContaining({
agentId: "main",
embedding: {
ok: false,
error,
},
},
}),
undefined,
);
};
@@ -71,11 +86,19 @@ describe("doctor.memory.status", () => {
});
expect(respond).toHaveBeenCalledWith(
true,
{
expect.objectContaining({
agentId: "main",
provider: "gemini",
embedding: { ok: true },
},
dreaming: expect.objectContaining({
mode: "off",
enabled: false,
shortTermCount: 0,
promotedTotal: 0,
promotedToday: 0,
managedCronPresent: false,
}),
}),
undefined,
);
expect(close).toHaveBeenCalled();
@@ -109,4 +132,110 @@ describe("doctor.memory.status", () => {
expectEmbeddingErrorResponse(respond, "gateway memory probe failed: timeout");
expect(close).toHaveBeenCalled();
});
it("includes dreaming counts and managed cron status when workspace data is available", async () => {
const now = Date.now();
const todayIso = new Date(now).toISOString();
const earlierIso = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: todayIso,
entries: {
"memory:memory/2026-04-03.md:1:2": {
path: "memory/2026-04-03.md",
source: "memory",
promotedAt: undefined,
},
"memory:memory/2026-04-02.md:1:2": {
path: "memory/2026-04-02.md",
source: "memory",
promotedAt: todayIso,
},
"memory:memory/2026-04-01.md:1:2": {
path: "memory/2026-04-01.md",
source: "memory",
promotedAt: earlierIso,
},
"memory:MEMORY.md:1:2": {
path: "MEMORY.md",
source: "memory",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
loadConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
mode: "rem",
frequency: "0 */4 * * *",
},
},
},
},
},
} as OpenClawConfig);
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ provider: "gemini", workspaceDir }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
close,
},
});
const cronList = vi.fn(async () => [
{
name: "Memory Dreaming Promotion",
description: "[managed-by=memory-core.short-term-promotion] test",
enabled: true,
payload: {
kind: "systemEvent",
text: "__openclaw_memory_core_short_term_promotion_dream__",
},
state: { nextRunAtMs: now + 60_000 },
},
]);
const respond = vi.fn();
try {
await invokeDoctorMemoryStatus(respond, { cron: { list: cronList } });
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
agentId: "main",
provider: "gemini",
embedding: { ok: true },
dreaming: expect.objectContaining({
mode: "rem",
enabled: true,
frequency: "0 */4 * * *",
shortTermCount: 1,
promotedTotal: 2,
promotedToday: 1,
managedCronPresent: true,
nextRunAtMs: now + 60_000,
}),
}),
undefined,
);
expect(close).toHaveBeenCalled();
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,9 +1,73 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { formatError } from "../server-utils.js";
import type { GatewayRequestHandlers } from "./types.js";
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
type DreamingMode = "off" | "core" | "rem" | "deep";
type DreamingPreset = Exclude<DreamingMode, "off">;
const DREAMING_PRESET_DEFAULTS: Record<
DreamingPreset,
{
frequency: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
}
> = {
core: {
frequency: "0 3 * * *",
limit: 10,
minScore: 0.75,
minRecallCount: 3,
minUniqueQueries: 2,
},
deep: {
frequency: "0 */12 * * *",
limit: 10,
minScore: 0.8,
minRecallCount: 3,
minUniqueQueries: 3,
},
rem: {
frequency: "0 */6 * * *",
limit: 10,
minScore: 0.85,
minRecallCount: 4,
minUniqueQueries: 3,
},
};
type DoctorMemoryDreamingPayload = {
mode: DreamingMode;
enabled: boolean;
frequency: string;
timezone?: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
shortTermCount: number;
promotedTotal: number;
promotedToday: number;
storePath?: string;
lastPromotedAt?: string;
nextRunAtMs?: number;
managedCronPresent: boolean;
storeError?: string;
};
export type DoctorMemoryStatusPayload = {
agentId: string;
provider?: string;
@@ -11,10 +75,256 @@ export type DoctorMemoryStatusPayload = {
ok: boolean;
error?: string;
};
dreaming?: DoctorMemoryDreamingPayload;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeDreamingMode(value: unknown): DreamingMode {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (
normalized === "off" ||
normalized === "core" ||
normalized === "rem" ||
normalized === "deep"
) {
return normalized;
}
return "off";
}
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const floored = Math.floor(value);
return floored < 0 ? fallback : floored;
}
function normalizeScore(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
if (value < 0 || value > 1) {
return fallback;
}
return value;
}
function resolveDreamingConfig(
cfg: Record<string, unknown>,
): Omit<
DoctorMemoryDreamingPayload,
| "shortTermCount"
| "promotedTotal"
| "promotedToday"
| "storePath"
| "lastPromotedAt"
| "nextRunAtMs"
| "managedCronPresent"
| "storeError"
> {
const plugins = asRecord(cfg.plugins);
const entries = asRecord(plugins?.entries);
const memoryCore = asRecord(entries?.["memory-core"]);
const pluginConfig = asRecord(memoryCore?.config);
const dreaming = asRecord(pluginConfig?.dreaming);
const mode = normalizeDreamingMode(dreaming?.mode);
const preset: DreamingPreset = mode === "off" ? "core" : mode;
const defaults = DREAMING_PRESET_DEFAULTS[preset];
return {
mode,
enabled: mode !== "off",
frequency: normalizeTrimmedString(dreaming?.frequency) ?? defaults.frequency,
timezone: normalizeTrimmedString(dreaming?.timezone),
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
minUniqueQueries: normalizeNonNegativeInt(
dreaming?.minUniqueQueries,
defaults.minUniqueQueries,
),
};
}
function normalizeMemoryPath(rawPath: string): string {
return rawPath.replaceAll("\\", "/").replace(/^\.\//, "");
}
function isShortTermMemoryPath(filePath: string): boolean {
const normalized = normalizeMemoryPath(filePath);
if (SHORT_TERM_PATH_RE.test(normalized)) {
return true;
}
return SHORT_TERM_BASENAME_RE.test(normalized);
}
function isSameLocalDay(firstEpochMs: number, secondEpochMs: number): boolean {
const first = new Date(firstEpochMs);
const second = new Date(secondEpochMs);
return (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
);
}
type DreamingStoreStats = Pick<
DoctorMemoryDreamingPayload,
| "shortTermCount"
| "promotedTotal"
| "promotedToday"
| "storePath"
| "lastPromotedAt"
| "storeError"
>;
async function loadDreamingStoreStats(
workspaceDir: string,
nowMs: number,
): Promise<DreamingStoreStats> {
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
try {
const raw = await fs.readFile(storePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
const store = asRecord(parsed);
const entries = asRecord(store?.entries) ?? {};
let shortTermCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
let latestPromotedAt: string | undefined;
for (const value of Object.values(entries)) {
const entry = asRecord(value);
if (!entry) {
continue;
}
const source = normalizeTrimmedString(entry.source);
const entryPath = normalizeTrimmedString(entry.path);
if (source !== "memory" || !entryPath || !isShortTermMemoryPath(entryPath)) {
continue;
}
const promotedAt = normalizeTrimmedString(entry.promotedAt);
if (!promotedAt) {
shortTermCount += 1;
continue;
}
promotedTotal += 1;
const promotedAtMs = Date.parse(promotedAt);
if (Number.isFinite(promotedAtMs) && isSameLocalDay(promotedAtMs, nowMs)) {
promotedToday += 1;
}
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
latestPromotedAtMs = promotedAtMs;
latestPromotedAt = promotedAt;
}
}
return {
shortTermCount,
promotedTotal,
promotedToday,
storePath,
...(latestPromotedAt ? { lastPromotedAt: latestPromotedAt } : {}),
};
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ENOENT") {
return {
shortTermCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath,
};
}
return {
shortTermCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath,
storeError: formatError(err),
};
}
}
type ManagedDreamingCronStatus = {
managedCronPresent: boolean;
nextRunAtMs?: number;
};
type ManagedCronJobLike = {
name?: string;
description?: string;
enabled?: boolean;
payload?: { kind?: string; text?: string };
state?: { nextRunAtMs?: number };
};
function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
const description = normalizeTrimmedString(job.description);
if (description?.includes(MANAGED_DREAMING_CRON_TAG)) {
return true;
}
const name = normalizeTrimmedString(job.name);
const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase();
const payloadText = normalizeTrimmedString(job.payload?.text);
return (
name === MANAGED_DREAMING_CRON_NAME &&
payloadKind === "systemevent" &&
payloadText === DREAMING_SYSTEM_EVENT_TEXT
);
}
async function resolveManagedDreamingCronStatus(context: {
cron?: { list?: (opts?: { includeDisabled?: boolean }) => Promise<unknown[]> };
}): Promise<ManagedDreamingCronStatus> {
if (!context.cron || typeof context.cron.list !== "function") {
return { managedCronPresent: false };
}
try {
const jobs = await context.cron.list({ includeDisabled: true });
const managed = jobs
.filter((job): job is ManagedCronJobLike => typeof job === "object" && job !== null)
.filter(isManagedDreamingJob);
let nextRunAtMs: number | undefined;
for (const job of managed) {
if (job.enabled !== true) {
continue;
}
const candidate = job.state?.nextRunAtMs;
if (typeof candidate !== "number" || !Number.isFinite(candidate)) {
continue;
}
if (nextRunAtMs === undefined || candidate < nextRunAtMs) {
nextRunAtMs = candidate;
}
}
return {
managedCronPresent: managed.length > 0,
...(nextRunAtMs !== undefined ? { nextRunAtMs } : {}),
};
} catch {
return { managedCronPresent: false };
}
}
export const doctorHandlers: GatewayRequestHandlers = {
"doctor.memory.status": async ({ respond }) => {
"doctor.memory.status": async ({ respond, context }) => {
const cfg = loadConfig();
const agentId = resolveDefaultAgentId(cfg);
const { manager, error } = await getActiveMemorySearchManager({
@@ -40,10 +350,26 @@ export const doctorHandlers: GatewayRequestHandlers = {
if (!embedding.ok && !embedding.error) {
embedding = { ok: false, error: "memory embeddings unavailable" };
}
const nowMs = Date.now();
const dreamingConfig = resolveDreamingConfig(cfg as Record<string, unknown>);
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
const storeStats = workspaceDir
? await loadDreamingStoreStats(workspaceDir, nowMs)
: {
shortTermCount: 0,
promotedTotal: 0,
promotedToday: 0,
};
const cronStatus = await resolveManagedDreamingCronStatus(context);
const payload: DoctorMemoryStatusPayload = {
agentId,
provider: status.provider,
embedding,
dreaming: {
...dreamingConfig,
...storeStats,
...cronStatus,
},
};
respond(true, payload, undefined);
} catch (err) {

View File

@@ -238,10 +238,11 @@
.dreams__stats {
position: absolute;
bottom: 40px;
bottom: 132px;
display: flex;
align-items: center;
gap: 48px;
z-index: 1;
}
.dreams__stat {
@@ -270,6 +271,118 @@
background: var(--border);
}
.dreams__controls {
display: none;
}
.dreams__controls-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dreams__controls-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 600;
}
.dreams__mode-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.dreams__mode {
appearance: none;
border: 1px solid var(--border);
background: color-mix(in oklab, var(--panel) 90%, transparent);
color: var(--text);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
cursor: pointer;
transition:
border-color 140ms ease,
transform 140ms ease,
background 140ms ease;
}
.dreams__mode:disabled {
opacity: 0.65;
cursor: default;
}
.dreams__mode:hover:not(:disabled) {
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
transform: translateY(-1px);
}
.dreams__mode--active {
border-color: color-mix(in oklab, var(--accent) 62%, var(--border));
background: color-mix(in oklab, var(--accent-subtle) 70%, var(--panel));
}
.dreams__mode-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-strong);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dreams__mode-detail {
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--muted);
}
.dreams__controls-note,
.dreams__controls-error {
font-size: 12px;
color: var(--muted);
}
.dreams__controls-error {
color: var(--danger);
}
@media (max-width: 880px) {
.dreams {
min-height: calc(100vh - 96px);
}
.dreams__stats {
position: static;
margin-top: 36px;
gap: 22px;
}
.dreams__controls {
position: static;
margin: 12px 16px 18px;
width: auto;
padding: 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: color-mix(in oklab, var(--panel) 84%, transparent);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: auto;
}
.dreams__mode-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ---- Status line ---- */
.dreams__status {

View File

@@ -924,6 +924,58 @@
.page-meta {
display: flex;
gap: 8px;
align-items: center;
}
.dreaming-header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.dreaming-header-controls__modes {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
}
.dreaming-header-controls__mode {
appearance: none;
border: 1px solid transparent;
border-radius: var(--radius-full);
padding: 4px 10px;
background: transparent;
color: var(--muted);
font-size: 11px;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
cursor: pointer;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.dreaming-header-controls__mode:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
color: var(--text);
}
.dreaming-header-controls__mode:disabled {
opacity: 0.55;
cursor: default;
}
.dreaming-header-controls__mode--active {
border-color: color-mix(in srgb, var(--accent) 62%, var(--border));
background: color-mix(in srgb, var(--accent-subtle) 70%, var(--bg-elevated));
color: var(--text-strong);
}
/* Chat view header adjustments */

View File

@@ -69,6 +69,11 @@ import {
revokeDeviceToken,
rotateDeviceToken,
} from "./controllers/devices.ts";
import {
loadDreamingStatus,
updateDreamingMode,
type DreamingMode,
} from "./controllers/dreaming.ts";
import {
loadExecApprovals,
removeExecApprovalsFormValue,
@@ -144,18 +149,41 @@ const lazyNodes = createLazy(() => import("./views/nodes.ts"));
const lazySessions = createLazy(() => import("./views/sessions.ts"));
const lazySkills = createLazy(() => import("./views/skills.ts"));
const lazyDreams = createLazy(() => import("./views/dreams.ts"));
const DREAMING_MODE_OPTIONS: Array<{ id: DreamingMode; label: string; detail: string }> = [
{ id: "off", label: "Off", detail: "No automatic promotions" },
{ id: "core", label: "Core", detail: "Nightly cadence" },
{ id: "rem", label: "REM", detail: "Every 6 hours" },
{ id: "deep", label: "Deep", detail: "Every 12 hours, stricter" },
];
function isDreamingEnabled(configValue: Record<string, unknown> | null): boolean {
function resolveDreamingMode(configValue: Record<string, unknown> | null): DreamingMode {
if (!configValue) {
return false;
return "off";
}
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 mode = dreaming?.mode;
return typeof mode === "string" && mode !== "off";
const mode = typeof dreaming?.mode === "string" ? dreaming.mode.trim().toLowerCase() : "";
if (mode === "core" || mode === "rem" || mode === "deep" || mode === "off") {
return mode;
}
return "off";
}
function isDreamingEnabled(configValue: Record<string, unknown> | null): boolean {
return resolveDreamingMode(configValue) !== "off";
}
function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
if (typeof nextRunAtMs !== "number" || !Number.isFinite(nextRunAtMs)) {
return null;
}
return new Date(nextRunAtMs).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
}
let clawhubSearchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -343,7 +371,25 @@ export function renderApp(state: AppViewState) {
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
const configValue =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const dreamingOn = isDreamingEnabled(configValue);
const configuredDreamingMode = resolveDreamingMode(configValue);
const dreamingMode = state.dreamingStatus?.mode ?? configuredDreamingMode;
const dreamingOn = state.dreamingStatus?.enabled ?? isDreamingEnabled(configValue);
const dreamingNextCycle = formatDreamNextCycle(state.dreamingStatus?.nextRunAtMs);
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
const refreshDreamingStatus = () => loadDreamingStatus(state);
const applyDreamingMode = (mode: DreamingMode) => {
if (state.dreamingModeSaving || mode === dreamingMode) {
return;
}
void (async () => {
const updated = await updateDreamingMode(state, mode);
if (!updated) {
return;
}
await loadConfig(state);
await loadDreamingStatus(state);
})();
};
const basePath = normalizeBasePath(state.basePath ?? "");
const resolvedAgentId =
state.agentsSelectedId ??
@@ -638,6 +684,39 @@ export function renderApp(state: AppViewState) {
${isChat ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
</div>
<div class="page-meta">
${state.tab === "dreams"
? html`
<div class="dreaming-header-controls">
<button
class="btn btn--subtle btn--sm"
?disabled=${dreamingLoading}
@click=${refreshDreamingStatus}
>
${state.dreamingStatusLoading ? "Refreshing…" : "Refresh"}
</button>
<div
class="dreaming-header-controls__modes"
role="group"
aria-label="Dreaming mode"
>
${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}
@click=${() => applyDreamingMode(option.id)}
>
${option.label}
</button>
`,
)}
</div>
</div>
`
: nothing}
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
@@ -2024,11 +2103,18 @@ export function renderApp(state: AppViewState) {
? lazyRender(lazyDreams, (m) =>
m.renderDreams({
active: dreamingOn,
shortTermCount: 0,
longTermCount: 0,
promotedCount: 0,
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
longTermCount: state.dreamingStatus?.promotedTotal ?? 0,
promotedCount: state.dreamingStatus?.promotedToday ?? 0,
dreamingOf: null,
nextCycle: null,
nextCycle: dreamingNextCycle,
mode: dreamingMode,
statusLoading: state.dreamingStatusLoading,
statusError: state.dreamingStatusError,
modeSaving: state.dreamingModeSaving,
managedCronPresent: state.dreamingStatus?.managedCronPresent ?? false,
onRefresh: refreshDreamingStatus,
onModeChange: applyDreamingMode,
}),
)
: nothing}

View File

@@ -17,6 +17,7 @@ import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
import { loadDebug } from "./controllers/debug.ts";
import { loadDevices } from "./controllers/devices.ts";
import { loadDreamingStatus } from "./controllers/dreaming.ts";
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
import { loadLogs } from "./controllers/logs.ts";
import { loadNodes } from "./controllers/nodes.ts";
@@ -59,6 +60,10 @@ type SettingsHost = {
pendingGatewayUrl?: string | null;
systemThemeCleanup?: (() => void) | null;
pendingGatewayToken?: string | null;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
dreamingModeSaving: boolean;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -267,6 +272,10 @@ export async function refreshActiveTab(host: SettingsHost) {
await loadConfig(host as unknown as OpenClawApp);
await loadExecApprovals(host as unknown as OpenClawApp);
}
if (host.tab === "dreams") {
await loadConfig(host as unknown as OpenClawApp);
await loadDreamingStatus(host as unknown as OpenClawApp);
}
if (host.tab === "chat") {
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
scheduleChatScroll(

View File

@@ -121,6 +121,10 @@ export type AppViewState = {
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
dreamingModeSaving: boolean;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;

View File

@@ -61,6 +61,7 @@ import {
} from "./controllers/agents.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { DreamingStatus } from "./controllers/dreaming.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
import type {
@@ -220,6 +221,10 @@ export class OpenClawApp extends LitElement {
@state() configUiHints: ConfigUiHints = {};
@state() configForm: Record<string, unknown> | null = null;
@state() configFormOriginal: Record<string, unknown> | null = null;
@state() dreamingStatusLoading = false;
@state() dreamingStatusError: string | null = null;
@state() dreamingStatus: DreamingStatus | null = null;
@state() dreamingModeSaving = false;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from "vitest";
import { loadDreamingStatus, updateDreamingMode, type DreamingState } from "./dreaming.ts";
function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn> } {
const request = vi.fn();
const state: DreamingState = {
client: {
request,
} as unknown as DreamingState["client"],
connected: true,
configSnapshot: { hash: "hash-1" },
applySessionKey: "main",
dreamingStatusLoading: false,
dreamingStatusError: null,
dreamingStatus: null,
dreamingModeSaving: false,
lastError: null,
};
return { state, request };
}
describe("dreaming controller", () => {
it("loads and normalizes dreaming status from doctor.memory.status", async () => {
const { state, request } = createState();
request.mockResolvedValue({
dreaming: {
mode: "rem",
enabled: true,
frequency: "0 */6 * * *",
timezone: "America/Los_Angeles",
limit: 10,
minScore: 0.85,
minRecallCount: 4,
minUniqueQueries: 3,
shortTermCount: 8,
promotedTotal: 21,
promotedToday: 2,
managedCronPresent: true,
nextRunAtMs: 12345,
},
});
await loadDreamingStatus(state);
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
expect(state.dreamingStatus).toEqual(
expect.objectContaining({
mode: "rem",
enabled: true,
shortTermCount: 8,
promotedToday: 2,
managedCronPresent: true,
nextRunAtMs: 12345,
}),
);
expect(state.dreamingStatusLoading).toBe(false);
expect(state.dreamingStatusError).toBeNull();
});
it("patches config to update dreaming mode", async () => {
const { state, request } = createState();
request.mockResolvedValue({ ok: true });
const ok = await updateDreamingMode(state, "deep");
expect(ok).toBe(true);
expect(request).toHaveBeenCalledWith(
"config.patch",
expect.objectContaining({
baseHash: "hash-1",
sessionKey: "main",
}),
);
expect(state.dreamingModeSaving).toBe(false);
expect(state.dreamingStatusError).toBeNull();
});
it("fails gracefully when config hash is missing", async () => {
const { state, request } = createState();
state.configSnapshot = {};
const ok = await updateDreamingMode(state, "core");
expect(ok).toBe(false);
expect(request).not.toHaveBeenCalled();
expect(state.dreamingStatusError).toContain("Config hash missing");
});
});

View File

@@ -0,0 +1,193 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ConfigSnapshot } from "../types.ts";
export type DreamingMode = "off" | "core" | "rem" | "deep";
export type DreamingStatus = {
mode: DreamingMode;
enabled: boolean;
frequency: string;
timezone?: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
shortTermCount: number;
promotedTotal: number;
promotedToday: number;
managedCronPresent: boolean;
nextRunAtMs?: number;
storePath?: string;
storeError?: string;
};
type DoctorMemoryStatusPayload = {
dreaming?: unknown;
};
export type DreamingState = {
client: GatewayBrowserClient | null;
connected: boolean;
configSnapshot: ConfigSnapshot | null;
applySessionKey: string;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: DreamingStatus | null;
dreamingModeSaving: boolean;
lastError: string | null;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeDreamingMode(value: unknown): DreamingMode {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (
normalized === "off" ||
normalized === "core" ||
normalized === "rem" ||
normalized === "deep"
) {
return normalized;
}
return "off";
}
function normalizeFiniteInt(value: unknown, fallback = 0): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.floor(value));
}
function normalizeFiniteScore(value: unknown, fallback = 0): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.min(1, value));
}
function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
const record = asRecord(raw);
if (!record) {
return null;
}
const mode = normalizeDreamingMode(record.mode);
const enabled = typeof record.enabled === "boolean" ? record.enabled : mode !== "off";
const frequency = normalizeTrimmedString(record.frequency) ?? "";
const timezone = normalizeTrimmedString(record.timezone);
const nextRunRaw = record.nextRunAtMs;
const nextRunAtMs =
typeof nextRunRaw === "number" && Number.isFinite(nextRunRaw) ? nextRunRaw : undefined;
return {
mode,
enabled,
frequency,
...(timezone ? { timezone } : {}),
limit: normalizeFiniteInt(record.limit, 0),
minScore: normalizeFiniteScore(record.minScore, 0),
minRecallCount: normalizeFiniteInt(record.minRecallCount, 0),
minUniqueQueries: normalizeFiniteInt(record.minUniqueQueries, 0),
shortTermCount: normalizeFiniteInt(record.shortTermCount, 0),
promotedTotal: normalizeFiniteInt(record.promotedTotal, 0),
promotedToday: normalizeFiniteInt(record.promotedToday, 0),
managedCronPresent: record.managedCronPresent === true,
...(nextRunAtMs !== undefined ? { nextRunAtMs } : {}),
...(normalizeTrimmedString(record.storePath)
? { storePath: normalizeTrimmedString(record.storePath) }
: {}),
...(normalizeTrimmedString(record.storeError)
? { storeError: normalizeTrimmedString(record.storeError) }
: {}),
};
}
export async function loadDreamingStatus(state: DreamingState): Promise<void> {
if (!state.client || !state.connected) {
return;
}
if (state.dreamingStatusLoading) {
return;
}
state.dreamingStatusLoading = true;
state.dreamingStatusError = null;
try {
const payload = await state.client.request<DoctorMemoryStatusPayload>(
"doctor.memory.status",
{},
);
state.dreamingStatus = normalizeDreamingStatus(payload?.dreaming);
} catch (err) {
state.dreamingStatusError = String(err);
} finally {
state.dreamingStatusLoading = false;
}
}
export async function updateDreamingMode(
state: DreamingState,
mode: DreamingMode,
): Promise<boolean> {
if (!state.client || !state.connected) {
return false;
}
if (state.dreamingModeSaving) {
return false;
}
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.dreamingStatusError = "Config hash missing; refresh and retry.";
return false;
}
state.dreamingModeSaving = true;
state.dreamingStatusError = null;
try {
await state.client.request("config.patch", {
baseHash,
raw: JSON.stringify({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
mode,
},
},
},
},
},
}),
sessionKey: state.applySessionKey,
note: "Dreaming mode updated from Dreams tab.",
});
if (state.dreamingStatus) {
state.dreamingStatus = {
...state.dreamingStatus,
mode,
enabled: mode !== "off",
};
}
return true;
} catch (err) {
const message = String(err);
state.dreamingStatusError = message;
state.lastError = message;
return false;
} finally {
state.dreamingModeSaving = false;
}
}

View File

@@ -12,6 +12,13 @@ function buildProps(overrides?: Partial<DreamsProps>): DreamsProps {
promotedCount: 12,
dreamingOf: null,
nextCycle: "4:00 AM",
mode: "core",
statusLoading: false,
statusError: null,
modeSaving: false,
managedCronPresent: true,
onRefresh: () => {},
onModeChange: () => {},
...overrides,
};
}
@@ -107,4 +114,14 @@ describe("dreams view", () => {
const detail = container.querySelector(".dreams__status-detail span");
expect(detail?.textContent).not.toContain("next cycle");
});
it("does not render setup controls in the dreams canvas", () => {
const container = renderInto(buildProps({ mode: "rem" }));
expect(container.querySelector(".dreams__controls")).toBeNull();
});
it("does not render canvas setup errors", () => {
const container = renderInto(buildProps({ statusError: "patch failed" }));
expect(container.querySelector(".dreams__controls-error")).toBeNull();
});
});

View File

@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import type { DreamingMode } from "../controllers/dreaming.ts";
export type DreamsProps = {
active: boolean;
@@ -7,6 +8,13 @@ export type DreamsProps = {
promotedCount: number;
dreamingOf: string | null;
nextCycle: string | null;
mode: DreamingMode;
statusLoading: boolean;
statusError: string | null;
modeSaving: boolean;
managedCronPresent: boolean;
onRefresh: () => void;
onModeChange: (mode: DreamingMode) => void;
};
const DREAM_PHRASES = [