Dreaming: simplify sweep flow and add diary surface

This commit is contained in:
Vignesh Natarajan
2026-04-05 17:16:49 -07:00
parent 02f2a66dff
commit 61e61ccc18
44 changed files with 4375 additions and 1470 deletions

View File

@@ -2,6 +2,48 @@
Dreams Tab Sleeping Lobster Animation
=========================================== */
.dreams-page {
display: flex;
flex-direction: column;
height: 100%;
}
/* ---- Sub-tab bar ---- */
.dreams__tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
flex-shrink: 0;
}
.dreams__tab {
padding: 4px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition:
color 140ms ease,
background 140ms ease;
}
.dreams__tab:hover {
color: var(--text);
}
.dreams__tab--active {
color: var(--text);
background: color-mix(in oklab, var(--panel) 80%, transparent);
}
.dreams {
position: relative;
display: flex;
@@ -237,11 +279,10 @@
/* ---- Stats bar ---- */
.dreams__stats {
position: absolute;
bottom: 132px;
display: flex;
align-items: center;
gap: 48px;
margin-top: 36px;
z-index: 1;
}
@@ -271,162 +312,63 @@
background: var(--border);
}
.dreams__controls {
position: absolute;
bottom: 24px;
width: min(820px, calc(100% - 48px));
padding: 14px;
border: 1px solid color-mix(in oklab, var(--accent) 18%, var(--border));
border-radius: 18px;
background: linear-gradient(
180deg,
color-mix(in oklab, var(--panel) 92%, transparent),
color-mix(in oklab, var(--panel) 82%, transparent)
);
backdrop-filter: blur(10px);
box-shadow: 0 18px 48px rgba(3, 7, 18, 0.24);
display: flex;
flex-direction: column;
gap: 12px;
z-index: 1;
}
/* ---- Dreaming on/off toggle (header bar) ---- */
.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__controls-subtitle {
margin-top: 4px;
font-size: 13px;
color: var(--muted);
}
.dreams__controls-actions {
display: flex;
align-items: center;
gap: 8px;
}
.dreams__phase-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.dreams__phase {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--panel) 90%, transparent);
color: var(--text);
border-radius: 14px;
padding: 12px;
transition:
border-color 140ms ease,
transform 140ms ease,
background 140ms ease;
}
.dreams__phase:hover {
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
transform: translateY(-1px);
}
.dreams__phase--active {
border-color: color-mix(in oklab, var(--accent) 62%, var(--border));
background: color-mix(in oklab, var(--accent-subtle) 70%, var(--panel));
}
.dreams__phase-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.dreams__phase-label {
font-size: 12px;
font-weight: 600;
color: var(--text-strong);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dreams__phase-detail {
margin-top: 4px;
font-size: 11px;
color: var(--muted);
}
.dreams__phase-meta {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dreams__phase-meta span {
.dreams__phase-toggle {
display: inline-flex;
align-items: center;
padding: 4px 7px;
gap: 8px;
padding: 6px 14px;
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--bg) 76%, transparent);
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid var(--border);
background: color-mix(in oklab, var(--panel) 70%, transparent);
color: var(--muted);
font-size: 11px;
font-family: inherit;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition:
border-color 200ms ease,
color 200ms ease;
}
.dreams__phase-toggle:hover {
border-color: color-mix(in oklab, var(--accent) 30%, var(--border));
}
.dreams__phase-toggle-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
opacity: 0.4;
transition:
background 200ms ease,
opacity 200ms ease,
box-shadow 200ms ease;
}
.dreams__phase-toggle--on .dreams__phase-toggle-dot {
background: var(--ok);
opacity: 1;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
}
.dreams__phase-toggle--on {
color: var(--text);
border-color: color-mix(in oklab, var(--ok) 20%, var(--border));
}
.dreams__phase-toggle-label {
font-weight: 500;
}
.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__phase-grid {
grid-template-columns: 1fr;
}
}
/* ---- Status line ---- */
.dreams__status {
@@ -487,3 +429,281 @@
.dreams--idle .dreams__status-detail {
color: var(--muted);
}
/* ===========================================
Dream Diary Scroll section below hero
=========================================== */
.dreams-diary {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px 64px;
flex: 1;
min-height: 320px;
background: linear-gradient(
180deg,
var(--bg) 0%,
color-mix(in oklab, var(--bg) 94%, #0d0818) 40%,
color-mix(in oklab, var(--bg) 88%, #0d0818) 100%
);
}
/* Ambient shimmer across the diary surface */
.dreams-diary::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
transparent 30%,
rgba(251, 191, 36, 0.012) 45%,
rgba(255, 77, 77, 0.015) 55%,
transparent 70%
);
background-size: 400% 400%;
animation: diary-shimmer 20s ease-in-out infinite;
pointer-events: none;
}
@keyframes diary-shimmer {
0%,
100% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
}
/* ---- Diary header ---- */
.dreams-diary__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
width: 100%;
max-width: 520px;
position: relative;
z-index: 1;
}
.dreams-diary__title {
font-size: 10px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
opacity: 0.7;
flex: 1;
}
.dreams-diary__nav {
display: flex;
align-items: center;
gap: 4px;
}
.dreams-diary__page {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: center;
opacity: 0.6;
}
.dreams-diary__nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid color-mix(in oklab, var(--border) 60%, transparent);
background: transparent;
color: var(--muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
transition:
color 140ms ease,
border-color 140ms ease;
padding: 0;
}
.dreams-diary__nav-btn:hover:not(:disabled) {
color: var(--text);
border-color: color-mix(in oklab, var(--accent) 30%, var(--border));
}
.dreams-diary__nav-btn:disabled {
opacity: 0.2;
cursor: default;
}
/* ---- Diary entry ---- */
.dreams-diary__entry {
position: relative;
max-width: 520px;
width: 100%;
padding: 0 0 0 20px;
z-index: 1;
animation: diary-entry-reveal 1.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes diary-entry-reveal {
0% {
opacity: 0;
transform: translateY(16px);
filter: blur(8px);
}
50% {
filter: blur(2px);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
/* Left accent glow line */
.dreams-diary__accent {
position: absolute;
top: 4px;
left: 0;
width: 2px;
height: calc(100% - 8px);
border-radius: 2px;
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 40%, transparent) 0%,
color-mix(in oklab, var(--accent) 8%, transparent) 100%
);
animation: diary-glow-pulse 6s ease-in-out infinite alternate;
}
@keyframes diary-glow-pulse {
0% {
opacity: 0.25;
}
100% {
opacity: 0.65;
}
}
.dreams-diary__date {
display: block;
font-size: 10px;
color: var(--accent-muted);
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 400;
margin-bottom: 16px;
opacity: 0.8;
}
.dreams-diary__prose {
/* no styling container needed, just prose spacing */
}
.dreams-diary__para {
margin: 0 0 12px;
font-size: 14px;
line-height: 1.8;
color: color-mix(in oklab, var(--text) 85%, var(--muted));
font-style: italic;
animation: diary-text-stream 2.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.dreams-diary__para:last-child {
margin-bottom: 0;
}
@keyframes diary-text-stream {
0% {
opacity: 0;
mask-image: linear-gradient(90deg, black 0%, transparent 0%);
-webkit-mask-image: linear-gradient(90deg, black 0%, transparent 0%);
}
30% {
opacity: 1;
}
100% {
opacity: 1;
mask-image: linear-gradient(90deg, black 100%, transparent 100%);
-webkit-mask-image: linear-gradient(90deg, black 100%, transparent 100%);
}
}
/* ---- Empty / error states ---- */
.dreams-diary__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 0;
z-index: 1;
}
.dreams-diary__empty-moon {
color: var(--muted);
opacity: 0.4;
margin-bottom: 4px;
}
.dreams-diary__empty-text {
font-size: 13px;
font-style: italic;
color: var(--muted);
opacity: 0.7;
}
.dreams-diary__empty-hint {
font-size: 11px;
color: var(--muted);
opacity: 0.4;
}
.dreams-diary__error {
font-size: 12px;
color: var(--danger);
text-align: center;
padding: 20px 0;
z-index: 1;
}
/* ---- Responsive ---- */
@media (max-width: 880px) {
.dreams {
min-height: calc(100vh - 96px);
}
.dreams__stats {
gap: 22px;
}
.dreams__phase-bar {
flex-wrap: wrap;
gap: 8px;
padding: 10px 16px;
}
.dreams__phase-bar-phases {
gap: 10px;
}
.dreams-diary {
padding: 32px 16px 48px;
}
.dreams-diary__entry {
padding-left: 16px;
}
}

View File

@@ -65,9 +65,9 @@ import {
rotateDeviceToken,
} from "./controllers/devices.ts";
import {
loadDreamDiary,
loadDreamingStatus,
updateDreamingMode,
type DreamingMode,
updateDreamingEnabled,
} from "./controllers/dreaming.ts";
import {
loadExecApprovals,
@@ -149,34 +149,22 @@ 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_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): {
mode: DreamingMode;
enabled: boolean;
} {
const fallback: DreamingMode = "off";
if (!configValue) {
return { mode: fallback };
return {
enabled: false,
};
}
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 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 {
mode: fallback,
enabled: typeof dreaming?.enabled === "boolean" ? dreaming.enabled : false,
};
}
@@ -191,20 +179,12 @@ function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
}
function resolveDreamingNextCycle(
status: {
phases: {
deep?: { enabled: boolean; nextRunAtMs?: number };
light?: { enabled: boolean; nextRunAtMs?: number };
rem?: { enabled: boolean; nextRunAtMs?: number };
};
} | null,
status: { phases: Record<string, { enabled: boolean; nextRunAtMs?: number }> } | null,
): string | null {
if (!status) {
return null;
}
const phases = [status.phases.deep, status.phases.light, status.phases.rem];
const nextRunAtMs = phases
.filter((phase): phase is { enabled: boolean; nextRunAtMs?: number } => Boolean(phase))
const nextRunAtMs = Object.values(status.phases)
.filter((phase) => phase.enabled && typeof phase.nextRunAtMs === "number")
.map((phase) => phase.nextRunAtMs as number)
.toSorted((a, b) => a - b)[0];
@@ -397,17 +377,19 @@ export function renderApp(state: AppViewState) {
const configValue =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const configuredDreaming = resolveConfiguredDreaming(configValue);
const dreamingMode = configuredDreaming.mode;
const dreamingOn = dreamingMode !== "off";
const dreamingOn = state.dreamingStatus?.enabled ?? configuredDreaming.enabled;
const dreamingNextCycle = resolveDreamingNextCycle(state.dreamingStatus);
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
const refreshDreamingStatus = () => loadDreamingStatus(state);
const applyDreamingMode = (mode: DreamingMode) => {
if (state.dreamingModeSaving || dreamingMode === mode) {
const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading;
const refreshDreaming = () => {
void Promise.all([loadDreamingStatus(state), loadDreamDiary(state)]);
};
const applyDreamingEnabled = (enabled: boolean) => {
if (state.dreamingModeSaving || dreamingOn === enabled) {
return;
}
void (async () => {
const updated = await updateDreamingMode(state, mode);
const updated = await updateDreamingEnabled(state, enabled);
if (!updated) {
return;
}
@@ -714,37 +696,23 @@ export function renderApp(state: AppViewState) {
<div class="dreaming-header-controls">
<button
class="btn btn--subtle btn--sm"
?disabled=${dreamingLoading}
@click=${refreshDreamingStatus}
?disabled=${dreamingLoading || state.dreamDiaryLoading}
@click=${refreshDreaming}
>
${state.dreamingStatusLoading ? "Refreshing…" : "Refresh"}
${dreamingRefreshLoading ? "Refreshing…" : "Refresh"}
</button>
<div
class="dreaming-header-controls__modes"
role="group"
aria-label="Dreaming mode"
<button
class="dreams__phase-toggle ${dreamingOn
? "dreams__phase-toggle--on"
: ""}"
?disabled=${dreamingLoading}
@click=${() => applyDreamingEnabled(!dreamingOn)}
>
${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>
<span class="dreams__phase-toggle-dot"></span>
<span class="dreams__phase-toggle-label"
>${dreamingOn ? "Dreaming On" : "Dreaming Off"}</span
>
</button>
</div>
`
: nothing}
@@ -2134,19 +2102,23 @@ 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,
modes: DREAMING_MODE_OPTIONS,
statusLoading: state.dreamingStatusLoading,
statusError: state.dreamingStatusError,
modeSaving: state.dreamingModeSaving,
onRefresh: refreshDreamingStatus,
onSelectMode: applyDreamingMode,
dreamDiaryLoading: state.dreamDiaryLoading,
dreamDiaryError: state.dreamDiaryError,
dreamDiaryPath: state.dreamDiaryPath,
dreamDiaryContent: state.dreamDiaryContent,
onRefresh: refreshDreaming,
onRefreshDiary: () => loadDreamDiary(state),
onToggleEnabled: applyDreamingEnabled,
onRequestUpdate: requestHostUpdate,
}),
)
: nothing}

View File

@@ -69,6 +69,10 @@ type SettingsHost = {
dreamingStatusError: string | null;
dreamingStatus: null;
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
};
function setTestWindowUrl(urlString: string) {
@@ -153,6 +157,10 @@ const createHost = (tab: Tab): SettingsHost => ({
dreamingStatusError: null,
dreamingStatus: null,
dreamingModeSaving: false,
dreamDiaryLoading: false,
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,
});
describe("setTabFromRoute", () => {

View File

@@ -17,7 +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 { loadDreamDiary, loadDreamingStatus } from "./controllers/dreaming.ts";
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
import { loadLogs } from "./controllers/logs.ts";
import { loadNodes } from "./controllers/nodes.ts";
@@ -64,6 +64,10 @@ type SettingsHost = {
dreamingStatusError: string | null;
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -274,7 +278,10 @@ export async function refreshActiveTab(host: SettingsHost) {
}
if (host.tab === "dreams") {
await loadConfig(host as unknown as OpenClawApp);
await loadDreamingStatus(host as unknown as OpenClawApp);
await Promise.all([
loadDreamingStatus(host as unknown as OpenClawApp),
loadDreamDiary(host as unknown as OpenClawApp),
]);
}
if (host.tab === "chat") {
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);

View File

@@ -125,6 +125,10 @@ export type AppViewState = {
dreamingStatusError: string | null;
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;

View File

@@ -225,6 +225,10 @@ export class OpenClawApp extends LitElement {
@state() dreamingStatusError: string | null = null;
@state() dreamingStatus: DreamingStatus | null = null;
@state() dreamingModeSaving = false;
@state() dreamDiaryLoading = false;
@state() dreamDiaryError: string | null = null;
@state() dreamDiaryPath: string | null = null;
@state() dreamDiaryContent: string | null = null;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";

View File

@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { loadDreamingStatus, updateDreamingMode, type DreamingState } from "./dreaming.ts";
import {
loadDreamDiary,
loadDreamingStatus,
updateDreamingEnabled,
type DreamingState,
} from "./dreaming.ts";
function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn> } {
const request = vi.fn();
@@ -14,6 +19,10 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
dreamingStatusError: null,
dreamingStatus: null,
dreamingModeSaving: false,
dreamDiaryLoading: false,
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,
lastError: null,
};
return { state, request };
@@ -24,7 +33,6 @@ describe("dreaming controller", () => {
const { state, request } = createState();
request.mockResolvedValue({
dreaming: {
mode: "core",
enabled: true,
timezone: "America/Los_Angeles",
verboseLogging: false,
@@ -72,7 +80,6 @@ describe("dreaming controller", () => {
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
expect(state.dreamingStatus).toEqual(
expect.objectContaining({
mode: "core",
enabled: true,
shortTermCount: 8,
promotedToday: 2,
@@ -88,18 +95,17 @@ describe("dreaming controller", () => {
expect(state.dreamingStatusError).toBeNull();
});
it("patches config to update dreaming mode", async () => {
it("patches config to update global dreaming enablement", async () => {
const { state, request } = createState();
request.mockResolvedValue({ ok: true });
const ok = await updateDreamingMode(state, "deep");
const ok = await updateDreamingEnabled(state, false);
expect(ok).toBe(true);
expect(request).toHaveBeenCalledWith(
"config.patch",
expect.objectContaining({
baseHash: "hash-1",
raw: expect.stringContaining('"mode":"deep"'),
sessionKey: "main",
}),
);
@@ -111,10 +117,50 @@ describe("dreaming controller", () => {
const { state, request } = createState();
state.configSnapshot = {};
const ok = await updateDreamingMode(state, "core");
const ok = await updateDreamingEnabled(state, true);
expect(ok).toBe(false);
expect(request).not.toHaveBeenCalled();
expect(state.dreamingStatusError).toContain("Config hash missing");
});
it("loads dream diary content", async () => {
const { state, request } = createState();
request.mockResolvedValue({
found: true,
path: "DREAMS.md",
content: "## Dream Diary\n- recurring glacier thoughts",
});
await loadDreamDiary(state);
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
expect(state.dreamDiaryPath).toBe("DREAMS.md");
expect(state.dreamDiaryContent).toContain("glacier");
expect(state.dreamDiaryError).toBeNull();
});
it("handles missing dream diary without error", async () => {
const { state, request } = createState();
request.mockResolvedValue({
found: false,
path: "DREAMS.md",
});
await loadDreamDiary(state);
expect(state.dreamDiaryPath).toBe("DREAMS.md");
expect(state.dreamDiaryContent).toBeNull();
expect(state.dreamDiaryError).toBeNull();
});
it("records dream diary request errors", async () => {
const { state, request } = createState();
request.mockRejectedValue(new Error("dream diary read failed"));
await loadDreamDiary(state);
expect(state.dreamDiaryError).toContain("dream diary read failed");
expect(state.dreamDiaryLoading).toBe(false);
});
});

View File

@@ -1,7 +1,8 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ConfigSnapshot } from "../types.ts";
export type DreamingMode = "off" | "core" | "rem" | "deep";
export type DreamingPhaseId = "light" | "deep" | "rem";
const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md";
type DreamingPhaseStatusBase = {
enabled: boolean;
@@ -31,7 +32,6 @@ type RemDreamingStatus = DreamingPhaseStatusBase & {
};
export type DreamingStatus = {
mode: DreamingMode;
enabled: boolean;
timezone?: string;
verboseLogging: boolean;
@@ -53,6 +53,12 @@ type DoctorMemoryStatusPayload = {
dreaming?: unknown;
};
type DoctorMemoryDreamDiaryPayload = {
found?: unknown;
path?: unknown;
content?: unknown;
};
export type DreamingState = {
client: GatewayBrowserClient | null;
connected: boolean;
@@ -62,6 +68,10 @@ export type DreamingState = {
dreamingStatusError: string | null;
dreamingStatus: DreamingStatus | null;
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
lastError: string | null;
};
@@ -106,19 +116,6 @@ 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;
}
@@ -146,13 +143,9 @@ 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 {
mode,
enabled,
enabled: normalizeBoolean(record.enabled, false),
...(timezone ? { timezone } : {}),
verboseLogging: normalizeBoolean(record.verboseLogging, false),
storageMode: normalizeStorageMode(record.storageMode),
@@ -208,6 +201,33 @@ export async function loadDreamingStatus(state: DreamingState): Promise<void> {
}
}
export async function loadDreamDiary(state: DreamingState): Promise<void> {
if (!state.client || !state.connected || state.dreamDiaryLoading) {
return;
}
state.dreamDiaryLoading = true;
state.dreamDiaryError = null;
try {
const payload = await state.client.request<DoctorMemoryDreamDiaryPayload>(
"doctor.memory.dreamDiary",
{},
);
const path = normalizeTrimmedString(payload?.path) ?? DEFAULT_DREAM_DIARY_PATH;
const found = payload?.found === true;
if (found) {
state.dreamDiaryPath = path;
state.dreamDiaryContent = typeof payload?.content === "string" ? payload.content : "";
} else {
state.dreamDiaryPath = path;
state.dreamDiaryContent = null;
}
} catch (err) {
state.dreamDiaryError = String(err);
} finally {
state.dreamDiaryLoading = false;
}
}
async function writeDreamingPatch(
state: DreamingState,
patch: Record<string, unknown>,
@@ -247,13 +267,6 @@ async function writeDreamingPatch(
export async function updateDreamingEnabled(
state: DreamingState,
enabled: boolean,
): Promise<boolean> {
return updateDreamingMode(state, enabled ? "core" : "off");
}
export async function updateDreamingMode(
state: DreamingState,
mode: DreamingMode,
): Promise<boolean> {
const ok = await writeDreamingPatch(state, {
plugins: {
@@ -261,7 +274,7 @@ export async function updateDreamingMode(
"memory-core": {
config: {
dreaming: {
mode,
enabled,
},
},
},
@@ -271,8 +284,7 @@ export async function updateDreamingMode(
if (ok && state.dreamingStatus) {
state.dreamingStatus = {
...state.dreamingStatus,
mode,
enabled: mode !== "off",
enabled,
};
}
return ok;

View File

@@ -130,6 +130,8 @@ describe("tabFromPath", () => {
expect(tabFromPath("/chat")).toBe("chat");
expect(tabFromPath("/overview")).toBe("overview");
expect(tabFromPath("/sessions")).toBe("sessions");
expect(tabFromPath("/dreaming")).toBe("dreams");
expect(tabFromPath("/dreams")).toBe("dreams");
});
it("returns chat for root path", () => {
@@ -159,6 +161,8 @@ describe("inferBasePathFromPathname", () => {
it("returns empty string for direct tab path", () => {
expect(inferBasePathFromPathname("/chat")).toBe("");
expect(inferBasePathFromPathname("/overview")).toBe("");
expect(inferBasePathFromPathname("/dreaming")).toBe("");
expect(inferBasePathFromPathname("/dreams")).toBe("");
});
it("infers base path from nested paths", () => {

View File

@@ -66,7 +66,14 @@ const TAB_PATHS: Record<Tab, string> = {
dreams: "/dreaming",
};
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
const PATH_ALIASES: Record<string, Tab> = {
"/dreams": "dreams",
};
const PATH_TO_TAB = new Map<string, Tab>([
...Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab] as const),
...Object.entries(PATH_ALIASES),
]);
export function normalizeBasePath(basePath: string): string {
if (!basePath) {

View File

@@ -1,46 +1,29 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderDreaming, type DreamingProps } from "./dreaming.ts";
import { describe, expect, it } from "vitest";
import { renderDreaming, setDreamSubTab, 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",
modes: [
{
id: "off",
label: "Off",
detail: "no automatic promotion",
},
{
id: "core",
label: "Core",
detail: "nightly durable consolidation",
},
{
id: "deep",
label: "Deep",
detail: "every 12 hours, conservative",
},
{
id: "rem",
label: "REM",
detail: "every 6 hours, stricter",
},
],
statusLoading: false,
statusError: null,
modeSaving: false,
dreamDiaryLoading: false,
dreamDiaryError: null,
dreamDiaryPath: "DREAMS.md",
dreamDiaryContent:
"# Dream Diary\n\n<!-- openclaw:dreaming:diary:start -->\n\n---\n\n*April 5, 2026, 3:00 AM*\n\nThe repository whispered of forgotten endpoints tonight.\n\n<!-- openclaw:dreaming:diary:end -->",
onRefresh: () => {},
onSelectMode: () => {},
onRefreshDiary: () => {},
onToggleEnabled: () => {},
...overrides,
};
}
@@ -95,21 +78,21 @@ describe("dreaming view", () => {
});
it("shows custom dreamingOf text when provided", () => {
const container = renderInto(buildProps({ dreamingOf: "reindexing old chats" }));
const container = renderInto(buildProps({ dreamingOf: "reindexing old chats\u2026" }));
const text = container.querySelector(".dreams__bubble-text");
expect(text?.textContent).toBe("reindexing old chats");
expect(text?.textContent).toBe("reindexing old chats\u2026");
});
it("shows active status label when active", () => {
const container = renderInto(buildProps({ active: true }));
const label = container.querySelector(".dreams__status-label");
expect(label?.textContent).toContain("Dreaming Active");
expect(label?.textContent).toBe("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).toContain("Dreaming Idle");
expect(label?.textContent).toBe("Dreaming Idle");
});
it("applies idle class when not active", () => {
@@ -123,12 +106,6 @@ describe("dreaming view", () => {
expect(detail?.textContent).toContain("4:00 AM");
});
it("renders mode controls", () => {
const container = renderInto(buildProps());
expect(container.querySelector(".dreams__controls")).not.toBeNull();
expect(container.querySelectorAll(".dreams__phase").length).toBe(4);
});
it("renders control error when present", () => {
const container = renderInto(buildProps({ statusError: "patch failed" }));
expect(container.querySelector(".dreams__controls-error")?.textContent).toContain(
@@ -136,12 +113,55 @@ describe("dreaming view", () => {
);
});
it("wires mode selection callbacks", () => {
const onSelectMode = vi.fn();
const container = renderInto(buildProps({ onSelectMode }));
container.querySelector<HTMLButtonElement>(".dreams__phase .btn")?.click();
expect(onSelectMode).toHaveBeenCalled();
it("renders sub-tab navigation", () => {
const container = renderInto(buildProps());
const tabs = container.querySelectorAll(".dreams__tab");
expect(tabs.length).toBe(2);
expect(tabs[0]?.textContent).toContain("Scene");
expect(tabs[1]?.textContent).toContain("Diary");
});
it("renders dream diary with parsed entry on diary tab", () => {
setDreamSubTab("diary");
const container = renderInto(buildProps());
const title = container.querySelector(".dreams-diary__title");
expect(title?.textContent).toContain("Dream Diary");
const entry = container.querySelector(".dreams-diary__entry");
expect(entry).not.toBeNull();
const date = container.querySelector(".dreams-diary__date");
expect(date?.textContent).toContain("April 5, 2026");
const body = container.querySelector(".dreams-diary__para");
expect(body?.textContent).toContain("forgotten endpoints");
setDreamSubTab("scene");
});
it("shows empty diary state when no diary content exists", () => {
setDreamSubTab("diary");
const container = renderInto(buildProps({ dreamDiaryContent: null }));
expect(container.querySelector(".dreams-diary__empty")).not.toBeNull();
expect(container.querySelector(".dreams-diary__empty-text")?.textContent).toContain(
"No dreams yet",
);
setDreamSubTab("scene");
});
it("shows diary error message when diary load fails", () => {
setDreamSubTab("diary");
const container = renderInto(buildProps({ dreamDiaryError: "read failed" }));
expect(container.querySelector(".dreams-diary__error")?.textContent).toContain("read failed");
setDreamSubTab("scene");
});
it("renders page navigation for diary entries", () => {
setDreamSubTab("diary");
const container = renderInto(buildProps());
const pageInfo = container.querySelector(".dreams-diary__page");
expect(pageInfo?.textContent).toContain("1 / 1");
const navBtns = container.querySelectorAll(".dreams-diary__nav-btn");
expect(navBtns.length).toBe(2);
setDreamSubTab("scene");
});
// Toggle lives in the page header (app-render.ts), not inside the dreaming view.
});

View File

@@ -1,51 +1,121 @@
import { html, nothing } from "lit";
import type { DreamingMode } from "../controllers/dreaming.ts";
// ── Diary entry parser ─────────────────────────────────────────────────
type DiaryEntry = {
date: string;
body: string;
};
const DIARY_START_RE = /<!--\s*openclaw:dreaming:diary:start\s*-->/;
const DIARY_END_RE = /<!--\s*openclaw:dreaming:diary:end\s*-->/;
function parseDiaryEntries(raw: string): DiaryEntry[] {
// Extract content between diary markers, or use full content.
let content = raw;
const startMatch = DIARY_START_RE.exec(raw);
const endMatch = DIARY_END_RE.exec(raw);
if (startMatch && endMatch && endMatch.index > startMatch.index) {
content = raw.slice(startMatch.index + startMatch[0].length, endMatch.index);
}
const entries: DiaryEntry[] = [];
// Split on --- separators.
const blocks = content.split(/\n---\n/).filter((b) => b.trim().length > 0);
for (const block of blocks) {
const lines = block.trim().split("\n");
let date = "";
const bodyLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Date lines are wrapped in *asterisks* like: *April 5, 2026, 3:00 AM*
if (!date && trimmed.startsWith("*") && trimmed.endsWith("*") && trimmed.length > 2) {
date = trimmed.slice(1, -1);
continue;
}
// Skip heading lines and HTML comments.
if (trimmed.startsWith("#") || trimmed.startsWith("<!--")) {
continue;
}
if (trimmed.length > 0) {
bodyLines.push(trimmed);
}
}
if (bodyLines.length > 0) {
entries.push({ date, body: bodyLines.join("\n") });
}
}
return entries;
}
export type DreamingProps = {
active: boolean;
mode: DreamingMode;
shortTermCount: number;
longTermCount: number;
promotedCount: number;
dreamingOf: string | null;
nextCycle: string | null;
timezone: string | null;
modes: Array<{
id: DreamingMode;
label: string;
detail: string;
}>;
statusLoading: boolean;
statusError: string | null;
modeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
onRefresh: () => void;
onSelectMode: (mode: DreamingMode) => void;
onRefreshDiary: () => void;
onToggleEnabled: (enabled: boolean) => void;
onRequestUpdate?: () => void;
};
const DREAM_PHRASES = [
"consolidating memories",
"tidying the knowledge graph",
"replaying today's conversations",
"weaving short-term into long-term",
"defragmenting the mind palace",
"filing away loose thoughts",
"connecting distant dots",
"composting old context windows",
"alphabetizing the subconscious",
"promoting promising hunches",
"forgetting what doesn't matter",
"dreaming in embeddings",
"reorganizing the memory attic",
"softly indexing the day",
"nurturing fledgling insights",
"simmering half-formed ideas",
"whispering to the vector store",
"consolidating memories\u2026",
"tidying the knowledge graph\u2026",
"replaying today's conversations\u2026",
"weaving short-term into long-term\u2026",
"defragmenting the mind palace\u2026",
"filing away loose thoughts\u2026",
"connecting distant dots\u2026",
"composting old context windows\u2026",
"alphabetizing the subconscious\u2026",
"promoting promising hunches\u2026",
"forgetting what doesn't matter\u2026",
"dreaming in embeddings\u2026",
"reorganizing the memory attic\u2026",
"softly indexing the day\u2026",
"nurturing fledgling insights\u2026",
"simmering half-formed ideas\u2026",
"whispering to the vector store\u2026",
];
let _dreamIndex = Math.floor(Math.random() * DREAM_PHRASES.length);
let _dreamLastSwap = 0;
const DREAM_SWAP_MS = 6_000;
// ── Sub-tab state ─────────────────────────────────────────────────────
type DreamSubTab = "scene" | "diary";
let _subTab: DreamSubTab = "scene";
export function setDreamSubTab(tab: DreamSubTab): void {
_subTab = tab;
}
// ── Diary pagination state ─────────────────────────────────────────────
let _diaryPage = 0;
let _diaryEntryCount = 0;
/** Navigate to a specific diary page. Triggers a re-render via Lit's reactive cycle. */
export function setDiaryPage(page: number): void {
_diaryPage = Math.max(0, Math.min(page, Math.max(0, _diaryEntryCount - 1)));
}
function currentDreamPhrase(): string {
const now = Date.now();
if (now - _dreamLastSwap > DREAM_SWAP_MS) {
@@ -116,6 +186,38 @@ export function renderDreaming(props: DreamingProps) {
const idle = !props.active;
const dreamText = props.dreamingOf ?? currentDreamPhrase();
return html`
<div class="dreams-page">
<!-- ── Sub-tab bar ── -->
<nav class="dreams__tabs">
<button
class="dreams__tab ${_subTab === "scene" ? "dreams__tab--active" : ""}"
@click=${() => {
_subTab = "scene";
props.onRequestUpdate?.();
}}
>
Scene
</button>
<button
class="dreams__tab ${_subTab === "diary" ? "dreams__tab--active" : ""}"
@click=${() => {
_subTab = "diary";
props.onRequestUpdate?.();
}}
>
Diary
</button>
</nav>
${_subTab === "scene" ? renderScene(props, idle, dreamText) : renderDiarySection(props)}
</div>
`;
}
// ── Scene renderer ────────────────────────────────────────────────────
function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
return html`
<section class="dreams ${idle ? "dreams--idle" : ""}">
${STARS.map(
@@ -160,13 +262,13 @@ export function renderDreaming(props: DreamingProps) {
<div class="dreams__status">
<span class="dreams__status-label"
>${props.active ? "Dreaming Active" : "Dreaming Idle"} · ${props.mode.toUpperCase()}</span
>${props.active ? "Dreaming Active" : "Dreaming Idle"}</span
>
<div class="dreams__status-detail">
<div class="dreams__status-dot"></div>
<span>
${props.promotedCount} promoted
${props.nextCycle ? html`· next run ${props.nextCycle}` : nothing}
${props.nextCycle ? html`· next sweep ${props.nextCycle}` : nothing}
${props.timezone ? html`· ${props.timezone}` : nothing}
</span>
</div>
@@ -195,63 +297,133 @@ export function renderDreaming(props: DreamingProps) {
</div>
</div>
<div class="dreams__controls">
<div class="dreams__controls-head">
<div>
<div class="dreams__controls-title">Dreaming Modes</div>
<div class="dreams__controls-subtitle">
Pick a cadence and threshold profile for durable promotion.
</div>
</div>
<div class="dreams__controls-actions">
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving}
@click=${props.onRefresh}
>
${props.statusLoading ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div class="dreams__phase-grid">
${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">${mode.label}</div>
<div class="dreams__phase-detail">${mode.detail}</div>
</div>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.mode === mode.id}
@click=${() => props.onSelectMode(mode.id)}
>
${props.mode === mode.id ? "Active" : "Set"}
</button>
</div>
<div class="dreams__phase-meta">
<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>
`,
)}
</div>
${props.statusError
? html`<div class="dreams__controls-error">${props.statusError}</div>`
: nothing}
</div>
${props.statusError
? html`<div class="dreams__controls-error">${props.statusError}</div>`
: nothing}
</section>
`;
}
// ── Diary section renderer ────────────────────────────────────────────
function renderDiarySection(props: DreamingProps) {
if (props.dreamDiaryError) {
return html`
<section class="dreams-diary">
<div class="dreams-diary__error">${props.dreamDiaryError}</div>
</section>
`;
}
if (typeof props.dreamDiaryContent !== "string") {
return html`
<section class="dreams-diary">
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-moon">
<svg viewBox="0 0 32 32" fill="none" width="32" height="32">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
stroke-width="0.5"
opacity="0.2"
/>
<path
d="M20 8a10 10 0 0 1 0 16 10 10 0 1 0 0-16z"
fill="currentColor"
opacity="0.08"
/>
</svg>
</div>
<div class="dreams-diary__empty-text">No dreams yet</div>
<div class="dreams-diary__empty-hint">
Dreams will appear here after the first dreaming cycle runs.
</div>
</div>
</section>
`;
}
const entries = parseDiaryEntries(props.dreamDiaryContent);
_diaryEntryCount = entries.length;
if (entries.length === 0) {
return html`
<section class="dreams-diary">
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">The diary is waiting</div>
<div class="dreams-diary__empty-hint">
Narrative entries will appear after the next dreaming cycle.
</div>
</div>
</section>
`;
}
// Show most recent entries first (reverse chronological).
const reversed = [...entries].toReversed();
// Clamp page.
const page = Math.max(0, Math.min(_diaryPage, reversed.length - 1));
const entry = reversed[page];
const hasPrev = page > 0;
const hasNext = page < reversed.length - 1;
return html`
<section class="dreams-diary">
<div class="dreams-diary__header">
<span class="dreams-diary__title">Dream Diary</span>
<div class="dreams-diary__nav">
<button
class="dreams-diary__nav-btn"
?disabled=${!hasNext}
@click=${() => {
setDiaryPage(page + 1);
props.onRequestUpdate?.();
}}
title="Older"
>
</button>
<span class="dreams-diary__page">${page + 1} / ${reversed.length}</span>
<button
class="dreams-diary__nav-btn"
?disabled=${!hasPrev}
@click=${() => {
setDiaryPage(page - 1);
props.onRequestUpdate?.();
}}
title="Newer"
>
</button>
</div>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryLoading}
@click=${() => {
_diaryPage = 0;
props.onRefreshDiary();
}}
>
${props.dreamDiaryLoading ? "\u2026" : "Reload"}
</button>
</div>
<article class="dreams-diary__entry" key="${page}">
<div class="dreams-diary__accent"></div>
${entry.date ? html`<time class="dreams-diary__date">${entry.date}</time>` : nothing}
<div class="dreams-diary__prose">
${entry.body
.split("\n")
.map(
(para, i) =>
html`<p class="dreams-diary__para" style="animation-delay: ${0.3 + i * 0.15}s;">
${para}
</p>`,
)}
</div>
</article>
</section>
`;
}