refactor(memory-core): rename sleep surface back to dreaming

This commit is contained in:
Vincent Koc
2026-04-05 18:13:49 +01:00
parent 848cc5e0ce
commit 8ff41a6bc4
27 changed files with 258 additions and 2251 deletions

View File

@@ -1,6 +1,7 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerMemoryCli } from "./src/cli.js";
import { registerSleepCommand } from "./src/dreaming-command.js";
import { registerDreamingCommand } from "./src/dreaming-command.js";
import { registerMemoryDreamingPhases } from "./src/dreaming-phases.js";
import { registerShortTermPromotionDreaming } from "./src/dreaming.js";
import {
buildMemoryFlushPlan,
@@ -11,7 +12,6 @@ import {
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
import { buildPromptSection } from "./src/prompt-section.js";
import { memoryRuntime } from "./src/runtime-provider.js";
import { registerMemorySleepPhases } from "./src/sleep.js";
import { createMemoryGetTool, createMemorySearchTool } from "./src/tools.js";
export {
buildMemoryFlushPlan,
@@ -29,8 +29,8 @@ export default definePluginEntry({
register(api) {
registerBuiltInMemoryEmbeddingProviders(api);
registerShortTermPromotionDreaming(api);
registerMemorySleepPhases(api);
registerSleepCommand(api);
registerMemoryDreamingPhases(api);
registerDreamingCommand(api);
api.registerMemoryPromptSection(buildPromptSection);
api.registerMemoryFlushPlan(buildMemoryFlushPlan);
api.registerMemoryRuntime(memoryRuntime);

View File

@@ -2,82 +2,82 @@
"id": "memory-core",
"kind": "memory",
"uiHints": {
"sleep.timezone": {
"label": "Sleep Timezone",
"dreaming.timezone": {
"label": "Dreaming Timezone",
"placeholder": "America/Los_Angeles",
"help": "IANA timezone used for sleep schedules and day bucketing."
"help": "IANA timezone used for dreaming schedules and day bucketing."
},
"sleep.verboseLogging": {
"label": "Sleep Verbose Logging",
"dreaming.verboseLogging": {
"label": "Dreaming Verbose Logging",
"placeholder": "false",
"help": "Emit detailed per-run sleep logs for phase scheduling, ranking, and writes."
"help": "Emit detailed per-run dreaming logs for phase scheduling, ranking, and writes."
},
"sleep.storage.mode": {
"label": "Sleep Storage Mode",
"dreaming.storage.mode": {
"label": "Dreaming Storage Mode",
"placeholder": "inline",
"help": "Write inline to MEMORY.md and daily notes, to separate reports, or both."
},
"sleep.phases.light.cron": {
"dreaming.phases.light.cron": {
"label": "Light Sleep Cron",
"placeholder": "0 3 * * *",
"help": "Cron cadence for light sleep sorting runs."
},
"sleep.phases.light.lookbackDays": {
"dreaming.phases.light.lookbackDays": {
"label": "Light Sleep Lookback Days",
"placeholder": "2",
"help": "How many prior days light sleep may inspect."
},
"sleep.phases.light.limit": {
"dreaming.phases.light.limit": {
"label": "Light Sleep Limit",
"placeholder": "100",
"help": "Maximum staged light-sleep candidates per run."
},
"sleep.phases.deep.cron": {
"dreaming.phases.deep.cron": {
"label": "Deep Sleep Cron",
"placeholder": "0 3 * * *",
"help": "Cron cadence for deep sleep promotion and recovery runs."
},
"sleep.phases.deep.limit": {
"dreaming.phases.deep.limit": {
"label": "Deep Sleep Limit",
"placeholder": "10",
"help": "Maximum candidates promoted into MEMORY.md per deep-sleep run."
},
"sleep.phases.deep.minScore": {
"dreaming.phases.deep.minScore": {
"label": "Deep Sleep Min Score",
"placeholder": "0.8",
"help": "Minimum weighted rank required for durable promotion."
},
"sleep.phases.deep.minRecallCount": {
"dreaming.phases.deep.minRecallCount": {
"label": "Deep Sleep Min Recalls",
"placeholder": "3",
"help": "Minimum recall count required for durable promotion."
},
"sleep.phases.deep.minUniqueQueries": {
"dreaming.phases.deep.minUniqueQueries": {
"label": "Deep Sleep Min Queries",
"placeholder": "3",
"help": "Minimum unique query count required for durable promotion."
},
"sleep.phases.deep.recencyHalfLifeDays": {
"dreaming.phases.deep.recencyHalfLifeDays": {
"label": "Deep Sleep Recency Half-Life Days",
"placeholder": "14",
"help": "Days for the recency score to decay by half during deep-sleep ranking."
},
"sleep.phases.deep.maxAgeDays": {
"dreaming.phases.deep.maxAgeDays": {
"label": "Deep Sleep Max Age Days",
"placeholder": "30",
"help": "Optional maximum candidate age in days for deep-sleep promotion."
},
"sleep.phases.deep.recovery.lookbackDays": {
"dreaming.phases.deep.recovery.lookbackDays": {
"label": "Recovery Lookback Days",
"placeholder": "30",
"help": "How many prior days deep sleep may scan when memory recovery is triggered."
},
"sleep.phases.rem.cron": {
"dreaming.phases.rem.cron": {
"label": "REM Sleep Cron",
"placeholder": "0 5 * * 0",
"help": "Cron cadence for REM sleep reflection runs."
},
"sleep.phases.rem.lookbackDays": {
"dreaming.phases.rem.lookbackDays": {
"label": "REM Sleep Lookback Days",
"placeholder": "7",
"help": "How many prior days REM sleep may inspect for patterns."
@@ -87,7 +87,7 @@
"type": "object",
"additionalProperties": false,
"properties": {
"sleep": {
"dreaming": {
"type": "object",
"additionalProperties": false,
"properties": {

View File

@@ -114,7 +114,7 @@ function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown>
return asRecord(entry?.config) ?? {};
}
function formatSleepSummary(cfg: OpenClawConfig): string {
function formatDreamingSummary(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryPluginConfig(cfg);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
if (!dreaming.enabled) {
@@ -577,7 +577,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(storePath)}`,
`${label("Workspace")} ${info(workspacePath)}`,
`${label("Sleep")} ${info(formatSleepSummary(cfg))}`,
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";

View File

@@ -367,7 +367,7 @@ describe("memory cli", () => {
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Recall store: 1 entries"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Sleep: 0 3 * * *"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Dreaming: 0 3 * * *"));
expect(close).toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import type {
} from "openclaw/plugin-sdk/core";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import { describe, expect, it, vi } from "vitest";
import { registerSleepCommand } from "./dreaming-command.js";
import { registerDreamingCommand } from "./dreaming-command.js";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -13,10 +13,10 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function resolveStoredSleep(config: OpenClawConfig): Record<string, unknown> {
function resolveStoredDreaming(config: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(config.plugins?.entries?.["memory-core"]);
const pluginConfig = asRecord(entry?.config);
return asRecord(pluginConfig?.sleep) ?? {};
return asRecord(pluginConfig?.dreaming) ?? {};
}
function createHarness(initialConfig: OpenClawConfig = {}) {
@@ -39,10 +39,10 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
}),
} as unknown as OpenClawPluginApi;
registerSleepCommand(api);
registerDreamingCommand(api);
if (!command) {
throw new Error("memory-core did not register /sleep");
throw new Error("memory-core did not register /dreaming");
}
return {
@@ -56,7 +56,7 @@ function createCommandContext(args?: string): PluginCommandContext {
return {
channel: "webchat",
isAuthorizedSender: true,
commandBody: args ? `/sleep ${args}` : "/sleep",
commandBody: args ? `/dreaming ${args}` : "/dreaming",
args,
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
@@ -65,20 +65,20 @@ function createCommandContext(args?: string): PluginCommandContext {
};
}
describe("memory-core /sleep command", () => {
describe("memory-core /dreaming command", () => {
it("registers with a phase-oriented description", () => {
const { command } = createHarness();
expect(command.name).toBe("sleep");
expect(command.name).toBe("dreaming");
expect(command.acceptsArgs).toBe(true);
expect(command.description).toContain("sleep phases");
expect(command.description).toContain("dreaming phases");
});
it("shows phase explanations when invoked without args", async () => {
const { command } = createHarness();
const result = await command.handler(createCommandContext());
expect(result.text).toContain("Usage: /sleep status");
expect(result.text).toContain("Sleep status:");
expect(result.text).toContain("Usage: /dreaming status");
expect(result.text).toContain("Dreaming status:");
expect(result.text).toContain("- light: sorts recent memory traces into the daily note.");
expect(result.text).toContain(
"- deep: promotes durable memories into MEMORY.md and handles recovery when memory is thin.",
@@ -88,13 +88,13 @@ describe("memory-core /sleep command", () => {
);
});
it("persists global enablement under plugins.entries.memory-core.config.sleep.enabled", async () => {
it("persists global enablement under plugins.entries.memory-core.config.dreaming.enabled", async () => {
const { command, runtime, getRuntimeConfig } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
sleep: {
dreaming: {
phases: {
deep: {
minScore: 0.9,
@@ -110,7 +110,7 @@ describe("memory-core /sleep command", () => {
const result = await command.handler(createCommandContext("off"));
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(resolveStoredSleep(getRuntimeConfig())).toMatchObject({
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
enabled: false,
phases: {
deep: {
@@ -118,23 +118,23 @@ describe("memory-core /sleep command", () => {
},
},
});
expect(result.text).toContain("Sleep disabled.");
expect(result.text).toContain("Dreaming disabled.");
});
it("persists phase changes under plugins.entries.memory-core.config.sleep.phases", async () => {
it("persists phase changes under plugins.entries.memory-core.config.dreaming.phases", async () => {
const { command, runtime, getRuntimeConfig } = createHarness();
const result = await command.handler(createCommandContext("disable rem"));
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(resolveStoredSleep(getRuntimeConfig())).toMatchObject({
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
phases: {
rem: {
enabled: false,
},
},
});
expect(result.text).toContain("REM sleep disabled.");
expect(result.text).toContain("REM phase disabled.");
});
it("returns status without mutating config", async () => {
@@ -143,7 +143,7 @@ describe("memory-core /sleep command", () => {
entries: {
"memory-core": {
config: {
sleep: {
dreaming: {
timezone: "America/Los_Angeles",
storage: {
mode: "both",
@@ -164,7 +164,7 @@ describe("memory-core /sleep command", () => {
const result = await command.handler(createCommandContext("status"));
expect(result.text).toContain("Sleep status:");
expect(result.text).toContain("Dreaming status:");
expect(result.text).toContain("- enabled: on (America/Los_Angeles)");
expect(result.text).toContain("- storage: both + reports");
expect(result.text).toContain("recencyHalfLifeDays=21");
@@ -176,7 +176,7 @@ describe("memory-core /sleep command", () => {
const { command, runtime } = createHarness();
const result = await command.handler(createCommandContext("unknown-mode"));
expect(result.text).toContain("Usage: /sleep status");
expect(result.text).toContain("Usage: /dreaming status");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,12 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
resolveMemoryLightSleepConfig,
resolveMemoryRemSleepConfig,
resolveMemorySleepConfig,
resolveMemoryLightDreamingConfig,
resolveMemoryRemDreamingConfig,
resolveMemoryDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
type SleepPhaseName = "light" | "deep" | "rem";
type DreamingPhaseName = "light" | "deep" | "rem";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -15,7 +15,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function normalizeSleepPhase(value: unknown): SleepPhaseName | null {
function normalizeDreamingPhase(value: unknown): DreamingPhaseName | null {
if (typeof value !== "string") {
return null;
}
@@ -31,16 +31,16 @@ function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unkn
return asRecord(entry?.config) ?? {};
}
function updateSleepEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig {
function updateDreamingEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig {
const entries = { ...(cfg.plugins?.entries ?? {}) };
const existingEntry = asRecord(entries["memory-core"]) ?? {};
const existingConfig = asRecord(existingEntry.config) ?? {};
const existingSleep = asRecord(existingConfig.sleep) ?? {};
const existingSleep = asRecord(existingConfig.dreaming) ?? {};
entries["memory-core"] = {
...existingEntry,
config: {
...existingConfig,
sleep: {
dreaming: {
...existingSleep,
enabled,
},
@@ -56,22 +56,22 @@ function updateSleepEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): Open
};
}
function updateSleepPhaseEnabledInConfig(
function updateDreamingPhaseEnabledInConfig(
cfg: OpenClawConfig,
phase: SleepPhaseName,
phase: DreamingPhaseName,
enabled: boolean,
): OpenClawConfig {
const entries = { ...(cfg.plugins?.entries ?? {}) };
const existingEntry = asRecord(entries["memory-core"]) ?? {};
const existingConfig = asRecord(existingEntry.config) ?? {};
const existingSleep = asRecord(existingConfig.sleep) ?? {};
const existingSleep = asRecord(existingConfig.dreaming) ?? {};
const existingPhases = asRecord(existingSleep.phases) ?? {};
const existingPhase = asRecord(existingPhases[phase]) ?? {};
entries["memory-core"] = {
...existingEntry,
config: {
...existingConfig,
sleep: {
dreaming: {
...existingSleep,
phases: {
...existingPhases,
@@ -107,20 +107,20 @@ function formatPhaseGuide(): string {
function formatStatus(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const sleep = resolveMemorySleepConfig({
const dreaming = resolveMemoryDreamingConfig({
pluginConfig,
cfg,
});
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
const light = resolveMemoryLightSleepConfig({ pluginConfig, cfg });
const rem = resolveMemoryRemSleepConfig({ pluginConfig, cfg });
const timezone = sleep.timezone ? ` (${sleep.timezone})` : "";
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg });
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg });
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
return [
"Sleep status:",
`- enabled: ${formatEnabled(sleep.enabled)}${timezone}`,
`- storage: ${sleep.storage.mode}${sleep.storage.separateReports ? " + reports" : ""}`,
`- verboseLogging: ${formatEnabled(sleep.verboseLogging)}`,
"Dreaming status:",
`- enabled: ${formatEnabled(dreaming.enabled)}${timezone}`,
`- storage: ${dreaming.storage.mode}${dreaming.storage.separateReports ? " + reports" : ""}`,
`- verboseLogging: ${formatEnabled(dreaming.verboseLogging)}`,
`- light: ${formatEnabled(light.enabled)} · cadence=${light.enabled ? light.cron : "disabled"} · lookbackDays=${light.lookbackDays} · limit=${light.limit}`,
`- deep: ${formatEnabled(deep.enabled)} · cadence=${deep.enabled ? deep.cron : "disabled"} · limit=${deep.limit} · minScore=${deep.minScore} · minRecallCount=${deep.minRecallCount} · minUniqueQueries=${deep.minUniqueQueries} · recencyHalfLifeDays=${deep.recencyHalfLifeDays} · maxAgeDays=${deep.maxAgeDays ?? "none"}`,
`- rem: ${formatEnabled(rem.enabled)} · cadence=${rem.enabled ? rem.cron : "disabled"} · lookbackDays=${rem.lookbackDays} · limit=${rem.limit} · minPatternStrength=${rem.minPatternStrength}`,
@@ -129,10 +129,10 @@ function formatStatus(cfg: OpenClawConfig): string {
function formatUsage(includeStatus: string): string {
return [
"Usage: /sleep status",
"Usage: /sleep on|off",
"Usage: /sleep enable light|deep|rem",
"Usage: /sleep disable light|deep|rem",
"Usage: /dreaming status",
"Usage: /dreaming on|off",
"Usage: /dreaming enable light|deep|rem",
"Usage: /dreaming disable light|deep|rem",
"",
includeStatus,
"",
@@ -141,10 +141,10 @@ function formatUsage(includeStatus: string): string {
].join("\n");
}
export function registerSleepCommand(api: OpenClawPluginApi): void {
export function registerDreamingCommand(api: OpenClawPluginApi): void {
api.registerCommand({
name: "sleep",
description: "Configure memory sleep phases and durable promotion behavior.",
name: "dreaming",
description: "Configure memory dreaming phases and durable promotion behavior.",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
@@ -169,23 +169,25 @@ export function registerSleepCommand(api: OpenClawPluginApi): void {
if (firstToken === "on" || firstToken === "off") {
const enabled = firstToken === "on";
const nextConfig = updateSleepEnabledInConfig(currentConfig, enabled);
await api.runtime.config.writeConfigFile(nextConfig);
return {
text: [`Sleep ${enabled ? "enabled" : "disabled"}.`, "", formatStatus(nextConfig)].join(
"\n",
),
};
}
const phase = normalizeSleepPhase(secondToken);
if ((firstToken === "enable" || firstToken === "disable") && phase) {
const enabled = firstToken === "enable";
const nextConfig = updateSleepPhaseEnabledInConfig(currentConfig, phase, enabled);
const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled);
await api.runtime.config.writeConfigFile(nextConfig);
return {
text: [
`${phase.toUpperCase()} sleep ${enabled ? "enabled" : "disabled"}.`,
`Dreaming ${enabled ? "enabled" : "disabled"}.`,
"",
formatStatus(nextConfig),
].join("\n"),
};
}
const phase = normalizeDreamingPhase(secondToken);
if ((firstToken === "enable" || firstToken === "disable") && phase) {
const enabled = firstToken === "enable";
const nextConfig = updateDreamingPhaseEnabledInConfig(currentConfig, phase, enabled);
await api.runtime.config.writeConfigFile(nextConfig);
return {
text: [
`${phase.toUpperCase()} phase ${enabled ? "enabled" : "disabled"}.`,
"",
formatStatus(nextConfig),
].join("\n"),
@@ -196,5 +198,3 @@ export function registerSleepCommand(api: OpenClawPluginApi): void {
},
});
}
export const registerDreamingCommand = registerSleepCommand;

View File

@@ -140,7 +140,7 @@ describe("short-term dreaming config", () => {
it("reads explicit dreaming config values", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
timezone: "UTC",
verboseLogging: true,
phases: {
@@ -178,7 +178,7 @@ describe("short-term dreaming config", () => {
it("accepts cron alias and numeric string thresholds", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
phases: {
deep: {
cron: "5 1 * * *",
@@ -213,7 +213,7 @@ describe("short-term dreaming config", () => {
it("treats blank numeric strings as unset and keeps preset defaults", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
phases: {
deep: {
limit: " ",
@@ -247,7 +247,7 @@ describe("short-term dreaming config", () => {
it("accepts limit=0 as an explicit no-op promotion cap", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
phases: {
deep: {
limit: 0,
@@ -262,14 +262,14 @@ describe("short-term dreaming config", () => {
it("accepts verboseLogging as a boolean or boolean string", () => {
const enabled = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
verboseLogging: true,
},
},
});
const disabled = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
verboseLogging: "false",
},
},
@@ -282,7 +282,7 @@ describe("short-term dreaming config", () => {
it("falls back to defaults when thresholds are negative", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
phases: {
deep: {
minScore: -0.2,
@@ -308,7 +308,7 @@ describe("short-term dreaming config", () => {
it("keeps deep sleep disabled when the phase is off", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
sleep: {
dreaming: {
phases: {
deep: {
enabled: false,

View File

@@ -1,21 +1,21 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
DEFAULT_MEMORY_DEEP_SLEEP_CRON_EXPR as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_MEMORY_DEEP_SLEEP_LIMIT as DEFAULT_MEMORY_DREAMING_LIMIT,
DEFAULT_MEMORY_DEEP_SLEEP_MIN_RECALL_COUNT as DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
DEFAULT_MEMORY_DEEP_SLEEP_MIN_SCORE as DEFAULT_MEMORY_DREAMING_MIN_SCORE,
DEFAULT_MEMORY_DEEP_SLEEP_MIN_UNIQUE_QUERIES as DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
DEFAULT_MEMORY_DEEP_SLEEP_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS,
DEFAULT_MEMORY_DEEP_DREAMING_CRON_EXPR as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_MEMORY_DEEP_DREAMING_LIMIT as DEFAULT_MEMORY_DREAMING_LIMIT,
DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT as DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE as DEFAULT_MEMORY_DREAMING_MIN_SCORE,
DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES as DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS,
resolveMemoryCorePluginConfig,
resolveMemoryDeepSleepConfig,
resolveMemorySleepWorkspaces,
resolveMemoryDeepDreamingConfig,
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { writeDeepDreamingReport } from "./dreaming-markdown.js";
import {
applyShortTermPromotions,
repairShortTermPromotionArtifacts,
rankShortTermPromotionCandidates,
} from "./short-term-promotion.js";
import { writeDeepSleepReport } from "./sleep-markdown.js";
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
@@ -267,7 +267,7 @@ export function resolveShortTermPromotionDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): ShortTermPromotionDreamingConfig {
const resolved = resolveMemoryDeepSleepConfig(params);
const resolved = resolveMemoryDeepDreamingConfig(params);
return {
enabled: resolved.enabled,
cron: resolved.cron,
@@ -372,7 +372,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
const recencyHalfLifeDays =
params.config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS;
const workspaceCandidates = params.cfg
? resolveMemorySleepWorkspaces(params.cfg).map((entry) => entry.workspaceDir)
? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir)
: [];
const seenWorkspaces = new Set<string>();
const workspaces = workspaceCandidates.filter((workspaceDir) => {
@@ -467,7 +467,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
`memory-core: dreaming applied details [workspace=${workspaceDir}] ${appliedSummary}`,
);
}
await writeDeepSleepReport({
await writeDeepDreamingReport({
workspaceDir,
bodyLines: reportLines,
timezone: params.config.timezone,

View File

@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { formatMemorySleepDay } from "openclaw/plugin-sdk/memory-core-host-status";
import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status";
import {
deriveConceptTags,
MAX_CONCEPT_TAGS,
@@ -605,7 +605,7 @@ export async function recordShortTermRecalls(params: {
const queryHashes = mergeQueryHashes(existing?.queryHashes ?? [], queryHash);
const recallDays = mergeRecentDistinct(
existing?.recallDays ?? [],
formatMemorySleepDay(nowMs, params.timezone),
formatMemoryDreamingDay(nowMs, params.timezone),
MAX_RECALL_DAYS,
);
const conceptTags = deriveConceptTags({ path: normalizedPath, snippet });
@@ -929,7 +929,7 @@ function buildPromotionSection(
nowMs: number,
timezone?: string,
): string {
const sectionDate = formatMemorySleepDay(nowMs, timezone);
const sectionDate = formatMemoryDreamingDay(nowMs, timezone);
const lines = ["", `## Promoted From Short-Term Memory (${sectionDate})`, ""];
for (const candidate of candidates) {

View File

@@ -1,155 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
formatMemorySleepDay,
type MemorySleepPhaseName,
type MemorySleepStorageConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
const DAILY_PHASE_HEADINGS: Record<Exclude<MemorySleepPhaseName, "deep">, string> = {
light: "## Light Sleep",
rem: "## REM Sleep",
};
const DAILY_PHASE_LABELS: Record<Exclude<MemorySleepPhaseName, "deep">, string> = {
light: "light",
rem: "rem",
};
function resolvePhaseMarkers(phase: Exclude<MemorySleepPhaseName, "deep">): {
start: string;
end: string;
} {
const label = DAILY_PHASE_LABELS[phase];
return {
start: `<!-- openclaw:sleep:${label}:start -->`,
end: `<!-- openclaw:sleep:${label}:end -->`,
};
}
function withTrailingNewline(content: string): string {
return content.endsWith("\n") ? content : `${content}\n`;
}
function replaceManagedBlock(params: {
original: string;
heading: string;
startMarker: string;
endMarker: string;
body: string;
}): string {
const managedBlock = `${params.heading}\n${params.startMarker}\n${params.body}\n${params.endMarker}`;
const existingPattern = new RegExp(
`${escapeRegex(params.heading)}\\n${escapeRegex(params.startMarker)}[\\s\\S]*?${escapeRegex(params.endMarker)}`,
"m",
);
if (existingPattern.test(params.original)) {
return params.original.replace(existingPattern, managedBlock);
}
const trimmed = params.original.trimEnd();
if (trimmed.length === 0) {
return `${managedBlock}\n`;
}
return `${trimmed}\n\n${managedBlock}\n`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveDailyMemoryPath(workspaceDir: string, epochMs: number, timezone?: string): string {
const isoDay = formatMemorySleepDay(epochMs, timezone);
return path.join(workspaceDir, "memory", `${isoDay}.md`);
}
function resolveSeparateReportPath(
workspaceDir: string,
phase: MemorySleepPhaseName,
epochMs: number,
timezone?: string,
): string {
const isoDay = formatMemorySleepDay(epochMs, timezone);
return path.join(workspaceDir, "memory", "sleep", phase, `${isoDay}.md`);
}
function shouldWriteInline(storage: MemorySleepStorageConfig): boolean {
return storage.mode === "inline" || storage.mode === "both";
}
function shouldWriteSeparate(storage: MemorySleepStorageConfig): boolean {
return storage.mode === "separate" || storage.mode === "both" || storage.separateReports;
}
export async function writeDailySleepPhaseBlock(params: {
workspaceDir: string;
phase: Exclude<MemorySleepPhaseName, "deep">;
bodyLines: string[];
nowMs?: number;
timezone?: string;
storage: MemorySleepStorageConfig;
}): Promise<{ inlinePath?: string; reportPath?: string }> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No notable updates.";
let inlinePath: string | undefined;
let reportPath: string | undefined;
if (shouldWriteInline(params.storage)) {
inlinePath = resolveDailyMemoryPath(params.workspaceDir, nowMs, params.timezone);
await fs.mkdir(path.dirname(inlinePath), { recursive: true });
const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
const markers = resolvePhaseMarkers(params.phase);
const updated = replaceManagedBlock({
original,
heading: DAILY_PHASE_HEADINGS[params.phase],
startMarker: markers.start,
endMarker: markers.end,
body,
});
await fs.writeFile(inlinePath, withTrailingNewline(updated), "utf-8");
}
if (shouldWriteSeparate(params.storage)) {
reportPath = resolveSeparateReportPath(
params.workspaceDir,
params.phase,
nowMs,
params.timezone,
);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const report = [
`# ${params.phase === "light" ? "Light Sleep" : "REM Sleep"}`,
"",
body,
"",
].join("\n");
await fs.writeFile(reportPath, report, "utf-8");
}
return {
...(inlinePath ? { inlinePath } : {}),
...(reportPath ? { reportPath } : {}),
};
}
export async function writeDeepSleepReport(params: {
workspaceDir: string;
bodyLines: string[];
nowMs?: number;
timezone?: string;
storage: MemorySleepStorageConfig;
}): Promise<string | undefined> {
if (!shouldWriteSeparate(params.storage)) {
return undefined;
}
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
return reportPath;
}

View File

@@ -1,657 +0,0 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
resolveMemoryCorePluginConfig,
resolveMemoryLightSleepConfig,
resolveMemoryRemSleepConfig,
resolveMemorySleepWorkspaces,
type MemoryLightSleepConfig,
type MemoryRemSleepConfig,
type MemorySleepPhaseName,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { readShortTermRecallEntries, type ShortTermRecallEntry } from "./short-term-promotion.js";
import { writeDailySleepPhaseBlock } from "./sleep-markdown.js";
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
type CronSchedule = { kind: "cron"; expr: string; tz?: string };
type CronPayload = { kind: "systemEvent"; text: string };
type ManagedCronJobCreate = {
name: string;
description: string;
enabled: boolean;
schedule: CronSchedule;
sessionTarget: "main";
wakeMode: "next-heartbeat";
payload: CronPayload;
};
type ManagedCronJobPatch = {
name?: string;
description?: string;
enabled?: boolean;
schedule?: CronSchedule;
sessionTarget?: "main";
wakeMode?: "next-heartbeat";
payload?: CronPayload;
};
type ManagedCronJobLike = {
id: string;
name?: string;
description?: string;
enabled?: boolean;
schedule?: {
kind?: string;
expr?: string;
tz?: string;
};
sessionTarget?: string;
wakeMode?: string;
payload?: {
kind?: string;
text?: string;
};
createdAtMs?: number;
};
type CronServiceLike = {
list: (opts?: { includeDisabled?: boolean }) => Promise<ManagedCronJobLike[]>;
add: (input: ManagedCronJobCreate) => Promise<unknown>;
update: (id: string, patch: ManagedCronJobPatch) => Promise<unknown>;
remove: (id: string) => Promise<{ removed?: boolean }>;
};
const LIGHT_SLEEP_CRON_NAME = "Memory Light Sleep";
const LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.sleep.light]";
const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const REM_SLEEP_CRON_NAME = "Memory REM Sleep";
const REM_SLEEP_CRON_TAG = "[managed-by=memory-core.sleep.rem]";
const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
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 formatErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}
function buildCronDescription(params: {
tag: string;
phase: "light" | "rem";
cron: string;
limit: number;
lookbackDays: number;
}): string {
return `${params.tag} Run ${params.phase} sleep (cron=${params.cron}, limit=${params.limit}, lookbackDays=${params.lookbackDays}).`;
}
function buildManagedCronJob(params: {
name: string;
tag: string;
payloadText: string;
cron: string;
timezone?: string;
phase: "light" | "rem";
limit: number;
lookbackDays: number;
}): ManagedCronJobCreate {
return {
name: params.name,
description: buildCronDescription({
tag: params.tag,
phase: params.phase,
cron: params.cron,
limit: params.limit,
lookbackDays: params.lookbackDays,
}),
enabled: true,
schedule: {
kind: "cron",
expr: params.cron,
...(params.timezone ? { tz: params.timezone } : {}),
},
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: params.payloadText,
},
};
}
function isManagedPhaseJob(
job: ManagedCronJobLike,
params: {
name: string;
tag: string;
payloadText: string;
},
): boolean {
const description = normalizeTrimmedString(job.description);
if (description?.includes(params.tag)) {
return true;
}
const name = normalizeTrimmedString(job.name);
const payloadText = normalizeTrimmedString(job.payload?.text);
return name === params.name && payloadText === params.payloadText;
}
function buildManagedPhasePatch(
job: ManagedCronJobLike,
desired: ManagedCronJobCreate,
): ManagedCronJobPatch | null {
const patch: ManagedCronJobPatch = {};
const scheduleKind = normalizeTrimmedString(job.schedule?.kind)?.toLowerCase();
const scheduleExpr = normalizeTrimmedString(job.schedule?.expr);
const scheduleTz = normalizeTrimmedString(job.schedule?.tz);
if (normalizeTrimmedString(job.name) !== desired.name) {
patch.name = desired.name;
}
if (normalizeTrimmedString(job.description) !== desired.description) {
patch.description = desired.description;
}
if (job.enabled !== true) {
patch.enabled = true;
}
if (
scheduleKind !== "cron" ||
scheduleExpr !== desired.schedule.expr ||
scheduleTz !== desired.schedule.tz
) {
patch.schedule = desired.schedule;
}
if (normalizeTrimmedString(job.sessionTarget)?.toLowerCase() !== "main") {
patch.sessionTarget = "main";
}
if (normalizeTrimmedString(job.wakeMode)?.toLowerCase() !== "next-heartbeat") {
patch.wakeMode = "next-heartbeat";
}
const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase();
const payloadText = normalizeTrimmedString(job.payload?.text);
if (payloadKind !== "systemevent" || payloadText !== desired.payload.text) {
patch.payload = desired.payload;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
return managed.toSorted((a, b) => {
const aCreated =
typeof a.createdAtMs === "number" && Number.isFinite(a.createdAtMs)
? a.createdAtMs
: Number.MAX_SAFE_INTEGER;
const bCreated =
typeof b.createdAtMs === "number" && Number.isFinite(b.createdAtMs)
? b.createdAtMs
: Number.MAX_SAFE_INTEGER;
if (aCreated !== bCreated) {
return aCreated - bCreated;
}
return a.id.localeCompare(b.id);
});
}
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
const payload = asRecord(event);
if (!payload || payload.type !== "gateway" || payload.action !== "startup") {
return null;
}
const context = asRecord(payload.context);
const deps = asRecord(context?.deps);
const cronCandidate = context?.cron ?? deps?.cron;
if (!cronCandidate || typeof cronCandidate !== "object") {
return null;
}
const cron = cronCandidate as Partial<CronServiceLike>;
if (
typeof cron.list !== "function" ||
typeof cron.add !== "function" ||
typeof cron.update !== "function" ||
typeof cron.remove !== "function"
) {
return null;
}
return cron as CronServiceLike;
}
async function reconcileManagedPhaseCronJob(params: {
cron: CronServiceLike | null;
desired: ManagedCronJobCreate;
match: { name: string; tag: string; payloadText: string };
enabled: boolean;
logger: Logger;
}): Promise<void> {
const cron = params.cron;
if (!cron) {
return;
}
const allJobs = await cron.list({ includeDisabled: true });
const managed = allJobs.filter((job) => isManagedPhaseJob(job, params.match));
if (!params.enabled) {
for (const job of managed) {
try {
await cron.remove(job.id);
} catch (err) {
params.logger.warn(
`memory-core: failed to remove managed ${params.match.name} cron job ${job.id}: ${formatErrorMessage(err)}`,
);
}
}
return;
}
if (managed.length === 0) {
await cron.add(params.desired);
return;
}
const [primary, ...duplicates] = sortManagedJobs(managed);
for (const duplicate of duplicates) {
try {
await cron.remove(duplicate.id);
} catch (err) {
params.logger.warn(
`memory-core: failed to prune duplicate managed ${params.match.name} cron job ${duplicate.id}: ${formatErrorMessage(err)}`,
);
}
}
const patch = buildManagedPhasePatch(primary, params.desired);
if (patch) {
await cron.update(primary.id, patch);
}
}
function resolveWorkspaces(params: {
cfg?: OpenClawConfig;
fallbackWorkspaceDir?: string;
}): string[] {
const workspaceCandidates = params.cfg
? resolveMemorySleepWorkspaces(params.cfg).map((entry) => entry.workspaceDir)
: [];
const seen = new Set<string>();
const workspaces = workspaceCandidates.filter((workspaceDir) => {
if (seen.has(workspaceDir)) {
return false;
}
seen.add(workspaceDir);
return true;
});
const fallbackWorkspaceDir = normalizeTrimmedString(params.fallbackWorkspaceDir);
if (workspaces.length === 0 && fallbackWorkspaceDir) {
workspaces.push(fallbackWorkspaceDir);
}
return workspaces;
}
function calculateLookbackCutoffMs(nowMs: number, lookbackDays: number): number {
return nowMs - Math.max(0, lookbackDays) * 24 * 60 * 60 * 1000;
}
function entryAverageScore(entry: ShortTermRecallEntry): number {
return entry.recallCount > 0 ? Math.max(0, Math.min(1, entry.totalScore / entry.recallCount)) : 0;
}
function tokenizeSnippet(snippet: string): Set<string> {
return new Set(
snippet
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter(Boolean),
);
}
function jaccardSimilarity(left: string, right: string): number {
const leftTokens = tokenizeSnippet(left);
const rightTokens = tokenizeSnippet(right);
if (leftTokens.size === 0 || rightTokens.size === 0) {
return left.trim().toLowerCase() === right.trim().toLowerCase() ? 1 : 0;
}
let intersection = 0;
for (const token of leftTokens) {
if (rightTokens.has(token)) {
intersection += 1;
}
}
const union = new Set([...leftTokens, ...rightTokens]).size;
return union > 0 ? intersection / union : 0;
}
function dedupeEntries(entries: ShortTermRecallEntry[], threshold: number): ShortTermRecallEntry[] {
const deduped: ShortTermRecallEntry[] = [];
for (const entry of entries) {
const duplicate = deduped.find(
(candidate) =>
candidate.path === entry.path &&
jaccardSimilarity(candidate.snippet, entry.snippet) >= threshold,
);
if (duplicate) {
if (entry.recallCount > duplicate.recallCount) {
duplicate.recallCount = entry.recallCount;
}
duplicate.totalScore = Math.max(duplicate.totalScore, entry.totalScore);
duplicate.maxScore = Math.max(duplicate.maxScore, entry.maxScore);
duplicate.queryHashes = [...new Set([...duplicate.queryHashes, ...entry.queryHashes])];
duplicate.recallDays = [
...new Set([...duplicate.recallDays, ...entry.recallDays]),
].toSorted();
duplicate.conceptTags = [...new Set([...duplicate.conceptTags, ...entry.conceptTags])];
duplicate.lastRecalledAt =
Date.parse(entry.lastRecalledAt) > Date.parse(duplicate.lastRecalledAt)
? entry.lastRecalledAt
: duplicate.lastRecalledAt;
continue;
}
deduped.push({ ...entry });
}
return deduped;
}
function buildLightSleepBody(entries: ShortTermRecallEntry[]): string[] {
if (entries.length === 0) {
return ["- No notable updates."];
}
const lines: string[] = [];
for (const entry of entries) {
const snippet = entry.snippet || "(no snippet captured)";
lines.push(`- Candidate: ${snippet}`);
lines.push(` - confidence: ${entryAverageScore(entry).toFixed(2)}`);
lines.push(` - evidence: ${entry.path}:${entry.startLine}-${entry.endLine}`);
lines.push(` - recalls: ${entry.recallCount}`);
lines.push(` - status: staged`);
}
return lines;
}
function buildRemSleepBody(
entries: ShortTermRecallEntry[],
limit: number,
minPatternStrength: number,
): string[] {
const tagStats = new Map<string, { count: number; evidence: Set<string> }>();
for (const entry of entries) {
for (const tag of entry.conceptTags) {
if (!tag) {
continue;
}
const stat = tagStats.get(tag) ?? { count: 0, evidence: new Set<string>() };
stat.count += 1;
stat.evidence.add(`${entry.path}:${entry.startLine}-${entry.endLine}`);
tagStats.set(tag, stat);
}
}
const ranked = [...tagStats.entries()]
.map(([tag, stat]) => {
const strength = Math.min(1, (stat.count / Math.max(1, entries.length)) * 2);
return { tag, strength, stat };
})
.filter((entry) => entry.strength >= minPatternStrength)
.toSorted(
(a, b) =>
b.strength - a.strength || b.stat.count - a.stat.count || a.tag.localeCompare(b.tag),
)
.slice(0, limit);
if (ranked.length === 0) {
return ["- No strong patterns surfaced."];
}
const lines: string[] = [];
for (const entry of ranked) {
lines.push(`- Theme: \`${entry.tag}\` kept surfacing across ${entry.stat.count} memories.`);
lines.push(` - confidence: ${entry.strength.toFixed(2)}`);
lines.push(` - evidence: ${[...entry.stat.evidence].slice(0, 3).join(", ")}`);
lines.push(` - note: reflection`);
}
return lines;
}
async function runLightSleep(params: {
workspaceDir: string;
config: MemoryLightSleepConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
};
logger: Logger;
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const cutoffMs = calculateLookbackCutoffMs(nowMs, params.config.lookbackDays);
const entries = dedupeEntries(
(await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }))
.filter((entry) => Date.parse(entry.lastRecalledAt) >= cutoffMs)
.toSorted((a, b) => {
const byTime = Date.parse(b.lastRecalledAt) - Date.parse(a.lastRecalledAt);
if (byTime !== 0) {
return byTime;
}
return b.recallCount - a.recallCount;
})
.slice(0, params.config.limit),
params.config.dedupeSimilarity,
);
const bodyLines = buildLightSleepBody(entries.slice(0, params.config.limit));
await writeDailySleepPhaseBlock({
workspaceDir: params.workspaceDir,
phase: "light",
bodyLines,
nowMs,
timezone: params.config.timezone,
storage: params.config.storage,
});
if (params.config.enabled && entries.length > 0 && params.config.storage.mode !== "separate") {
params.logger.info(
`memory-core: light sleep staged ${Math.min(entries.length, params.config.limit)} candidate(s) [workspace=${params.workspaceDir}].`,
);
}
}
async function runRemSleep(params: {
workspaceDir: string;
config: MemoryRemSleepConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
};
logger: Logger;
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const cutoffMs = calculateLookbackCutoffMs(nowMs, params.config.lookbackDays);
const entries = (
await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs })
).filter((entry) => Date.parse(entry.lastRecalledAt) >= cutoffMs);
const bodyLines = buildRemSleepBody(
entries,
params.config.limit,
params.config.minPatternStrength,
);
await writeDailySleepPhaseBlock({
workspaceDir: params.workspaceDir,
phase: "rem",
bodyLines,
nowMs,
timezone: params.config.timezone,
storage: params.config.storage,
});
if (params.config.enabled && entries.length > 0 && params.config.storage.mode !== "separate") {
params.logger.info(
`memory-core: REM sleep wrote reflections from ${entries.length} recent memory trace(s) [workspace=${params.workspaceDir}].`,
);
}
}
async function runPhaseIfTriggered(params: {
cleanedBody: string;
trigger?: string;
workspaceDir?: string;
cfg?: OpenClawConfig;
logger: Logger;
phase: "light" | "rem";
eventText: string;
config:
| (MemoryLightSleepConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
})
| (MemoryRemSleepConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
});
}): Promise<{ handled: true; reason: string } | undefined> {
if (params.trigger !== "heartbeat" || params.cleanedBody.trim() !== params.eventText) {
return undefined;
}
if (!params.config.enabled) {
return { handled: true, reason: `memory-core: ${params.phase} sleep disabled` };
}
const workspaces = resolveWorkspaces({
cfg: params.cfg,
fallbackWorkspaceDir: params.workspaceDir,
});
if (workspaces.length === 0) {
params.logger.warn(
`memory-core: ${params.phase} sleep skipped because no memory workspace is available.`,
);
return { handled: true, reason: `memory-core: ${params.phase} sleep missing workspace` };
}
if (params.config.limit === 0) {
params.logger.info(`memory-core: ${params.phase} sleep skipped because limit=0.`);
return { handled: true, reason: `memory-core: ${params.phase} sleep disabled by limit` };
}
for (const workspaceDir of workspaces) {
try {
if (params.phase === "light") {
await runLightSleep({
workspaceDir,
config: params.config as MemoryLightSleepConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
},
logger: params.logger,
});
} else {
await runRemSleep({
workspaceDir,
config: params.config as MemoryRemSleepConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
},
logger: params.logger,
});
}
} catch (err) {
params.logger.error(
`memory-core: ${params.phase} sleep failed for workspace ${workspaceDir}: ${formatErrorMessage(err)}`,
);
}
}
return { handled: true, reason: `memory-core: ${params.phase} sleep processed` };
}
export function registerMemorySleepPhases(api: OpenClawPluginApi): void {
api.registerHook(
"gateway:startup",
async (event: unknown) => {
const cron = resolveCronServiceFromStartupEvent(event);
const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig;
const light = resolveMemoryLightSleepConfig({ pluginConfig, cfg: api.config });
const rem = resolveMemoryRemSleepConfig({ pluginConfig, cfg: api.config });
const lightDesired = buildManagedCronJob({
name: LIGHT_SLEEP_CRON_NAME,
tag: LIGHT_SLEEP_CRON_TAG,
payloadText: LIGHT_SLEEP_EVENT_TEXT,
cron: light.cron,
timezone: light.timezone,
phase: "light",
limit: light.limit,
lookbackDays: light.lookbackDays,
});
const remDesired = buildManagedCronJob({
name: REM_SLEEP_CRON_NAME,
tag: REM_SLEEP_CRON_TAG,
payloadText: REM_SLEEP_EVENT_TEXT,
cron: rem.cron,
timezone: rem.timezone,
phase: "rem",
limit: rem.limit,
lookbackDays: rem.lookbackDays,
});
try {
await reconcileManagedPhaseCronJob({
cron,
desired: lightDesired,
match: {
name: LIGHT_SLEEP_CRON_NAME,
tag: LIGHT_SLEEP_CRON_TAG,
payloadText: LIGHT_SLEEP_EVENT_TEXT,
},
enabled: light.enabled,
logger: api.logger,
});
await reconcileManagedPhaseCronJob({
cron,
desired: remDesired,
match: {
name: REM_SLEEP_CRON_NAME,
tag: REM_SLEEP_CRON_TAG,
payloadText: REM_SLEEP_EVENT_TEXT,
},
enabled: rem.enabled,
logger: api.logger,
});
} catch (err) {
api.logger.error(
`memory-core: sleep startup reconciliation failed: ${formatErrorMessage(err)}`,
);
}
},
{ name: "memory-core-sleep-phase-cron" },
);
api.on("before_agent_reply", async (event, ctx) => {
const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig;
const light = resolveMemoryLightSleepConfig({ pluginConfig, cfg: api.config });
const lightResult = await runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: api.config,
logger: api.logger,
phase: "light",
eventText: LIGHT_SLEEP_EVENT_TEXT,
config: light,
});
if (lightResult) {
return lightResult;
}
const rem = resolveMemoryRemSleepConfig({ pluginConfig, cfg: api.config });
return await runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: api.config,
logger: api.logger,
phase: "rem",
eventText: REM_SLEEP_EVENT_TEXT,
config: rem,
});
});
}

View File

@@ -137,7 +137,7 @@ describe("memory_search recall tracking", () => {
}
});
it("passes the resolved sleep timezone into recall tracking", async () => {
it("passes the resolved dreaming timezone into recall tracking", async () => {
setMemorySearchImpl(async () => [
{
path: "memory/2026-04-03.md",
@@ -161,7 +161,7 @@ describe("memory_search recall tracking", () => {
entries: {
"memory-core": {
config: {
sleep: {
dreaming: {
timezone: "Europe/London",
},
},

View File

@@ -8,7 +8,7 @@ import {
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDeepSleepConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import {
@@ -108,7 +108,7 @@ export function createMemorySearchTool(options: {
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
const sleepTimezone = resolveMemoryDeepSleepConfig({
const sleepTimezone = resolveMemoryDeepDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
}).timezone;

View File

@@ -105,7 +105,7 @@ describe("doctor.memory.status", () => {
agentId: "main",
provider: "gemini",
embedding: { ok: true },
sleep: expect.objectContaining({
dreaming: expect.objectContaining({
enabled: true,
shortTermCount: 0,
promotedTotal: 0,
@@ -240,7 +240,7 @@ describe("doctor.memory.status", () => {
entries: {
"memory-core": {
config: {
sleep: {
dreaming: {
phases: {
deep: {
cron: "0 */4 * * *",
@@ -292,7 +292,7 @@ describe("doctor.memory.status", () => {
agentId: "main",
provider: "gemini",
embedding: { ok: true },
sleep: expect.objectContaining({
dreaming: expect.objectContaining({
enabled: true,
timezone: "America/Los_Angeles",
shortTermCount: 1,
@@ -347,7 +347,7 @@ describe("doctor.memory.status", () => {
entries: {
"memory-core": {
config: {
sleep: {},
dreaming: {},
},
},
},
@@ -369,7 +369,7 @@ describe("doctor.memory.status", () => {
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
sleep: expect.objectContaining({
dreaming: expect.objectContaining({
shortTermCount: 0,
promotedTotal: 1,
storePath,
@@ -429,7 +429,7 @@ describe("doctor.memory.status", () => {
entries: {
"memory-core": {
config: {
sleep: {},
dreaming: {},
},
},
},
@@ -475,10 +475,10 @@ describe("doctor.memory.status", () => {
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
sleep: expect.objectContaining({
dreaming: expect.objectContaining({
shortTermCount: 0,
promotedTotal: 0,
storeError: "2 sleep stores had read errors.",
storeError: "2 dreaming stores had read errors.",
}),
}),
undefined,

View File

@@ -4,42 +4,42 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
isSameMemorySleepDay,
isSameMemoryDreamingDay,
resolveMemoryCorePluginConfig,
resolveMemoryDeepSleepConfig,
resolveMemoryLightSleepConfig,
resolveMemoryRemSleepConfig,
resolveMemorySleepConfig,
resolveMemorySleepWorkspaces,
} from "../../memory-host-sdk/sleep.js";
resolveMemoryDeepDreamingConfig,
resolveMemoryLightDreamingConfig,
resolveMemoryRemDreamingConfig,
resolveMemoryDreamingConfig,
resolveMemoryDreamingWorkspaces,
} from "../../memory-host-sdk/dreaming.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 MANAGED_LIGHT_SLEEP_CRON_NAME = "Memory Light Sleep";
const MANAGED_LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.sleep.light]";
const MANAGED_LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming";
const MANAGED_LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]";
const LIGHT_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const MANAGED_DEEP_SLEEP_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DEEP_SLEEP_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DEEP_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
const MANAGED_REM_SLEEP_CRON_NAME = "Memory REM Sleep";
const MANAGED_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.sleep.rem]";
const MANAGED_REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const MANAGED_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const REM_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
type DoctorMemorySleepPhasePayload = {
type DoctorMemoryDreamingPhasePayload = {
enabled: boolean;
cron: string;
managedCronPresent: boolean;
nextRunAtMs?: number;
};
type DoctorMemoryLightSleepPayload = DoctorMemorySleepPhasePayload & {
type DoctorMemoryLightDreamingPayload = DoctorMemoryDreamingPhasePayload & {
lookbackDays: number;
limit: number;
};
type DoctorMemoryDeepSleepPayload = DoctorMemorySleepPhasePayload & {
type DoctorMemoryDeepDreamingPayload = DoctorMemoryDreamingPhasePayload & {
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
@@ -48,13 +48,13 @@ type DoctorMemoryDeepSleepPayload = DoctorMemorySleepPhasePayload & {
limit: number;
};
type DoctorMemoryRemSleepPayload = DoctorMemorySleepPhasePayload & {
type DoctorMemoryRemDreamingPayload = DoctorMemoryDreamingPhasePayload & {
lookbackDays: number;
limit: number;
minPatternStrength: number;
};
type DoctorMemorySleepPayload = {
type DoctorMemoryDreamingPayload = {
enabled: boolean;
timezone?: string;
verboseLogging: boolean;
@@ -67,9 +67,9 @@ type DoctorMemorySleepPayload = {
lastPromotedAt?: string;
storeError?: string;
phases: {
light: DoctorMemoryLightSleepPayload;
deep: DoctorMemoryDeepSleepPayload;
rem: DoctorMemoryRemSleepPayload;
light: DoctorMemoryLightDreamingPayload;
deep: DoctorMemoryDeepDreamingPayload;
rem: DoctorMemoryRemDreamingPayload;
};
};
@@ -80,7 +80,7 @@ export type DoctorMemoryStatusPayload = {
ok: boolean;
error?: string;
};
sleep?: DoctorMemorySleepPayload;
dreaming?: DoctorMemoryDreamingPayload;
};
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -98,10 +98,10 @@ function normalizeTrimmedString(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveSleepConfig(
function resolveDreamingConfig(
cfg: OpenClawConfig,
): Omit<
DoctorMemorySleepPayload,
DoctorMemoryDreamingPayload,
| "shortTermCount"
| "promotedTotal"
| "promotedToday"
@@ -109,19 +109,19 @@ function resolveSleepConfig(
| "lastPromotedAt"
| "storeError"
> {
const resolved = resolveMemorySleepConfig({
const resolved = resolveMemoryDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
});
const light = resolveMemoryLightSleepConfig({
const light = resolveMemoryLightDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
});
const deep = resolveMemoryDeepSleepConfig({
const deep = resolveMemoryDeepDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
});
const rem = resolveMemoryRemSleepConfig({
const rem = resolveMemoryRemDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
});
@@ -174,8 +174,8 @@ function isShortTermMemoryPath(filePath: string): boolean {
return /^(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized);
}
type SleepStoreStats = Pick<
DoctorMemorySleepPayload,
type DreamingStoreStats = Pick<
DoctorMemoryDreamingPayload,
| "shortTermCount"
| "promotedTotal"
| "promotedToday"
@@ -184,11 +184,11 @@ type SleepStoreStats = Pick<
| "storeError"
>;
async function loadSleepStoreStats(
async function loadDreamingStoreStats(
workspaceDir: string,
nowMs: number,
timezone?: string,
): Promise<SleepStoreStats> {
): Promise<DreamingStoreStats> {
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
try {
const raw = await fs.readFile(storePath, "utf-8");
@@ -218,7 +218,7 @@ async function loadSleepStoreStats(
}
promotedTotal += 1;
const promotedAtMs = Date.parse(promotedAt);
if (Number.isFinite(promotedAtMs) && isSameMemorySleepDay(promotedAtMs, nowMs, timezone)) {
if (Number.isFinite(promotedAtMs) && isSameMemoryDreamingDay(promotedAtMs, nowMs, timezone)) {
promotedToday += 1;
}
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
@@ -254,7 +254,7 @@ async function loadSleepStoreStats(
}
}
function mergeSleepStoreStats(stats: SleepStoreStats[]): SleepStoreStats {
function mergeDreamingStoreStats(stats: DreamingStoreStats[]): DreamingStoreStats {
let shortTermCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
@@ -289,12 +289,12 @@ function mergeSleepStoreStats(stats: SleepStoreStats[]): SleepStoreStats {
...(storeErrors.length === 1
? { storeError: storeErrors[0] }
: storeErrors.length > 1
? { storeError: `${storeErrors.length} sleep stores had read errors.` }
? { storeError: `${storeErrors.length} dreaming stores had read errors.` }
: {}),
};
}
type ManagedSleepCronStatus = {
type ManagedDreamingCronStatus = {
managedCronPresent: boolean;
nextRunAtMs?: number;
};
@@ -307,7 +307,7 @@ type ManagedCronJobLike = {
state?: { nextRunAtMs?: number };
};
function isManagedSleepJob(
function isManagedDreamingJob(
job: ManagedCronJobLike,
params: { name: string; tag: string; payloadText: string },
): boolean {
@@ -323,7 +323,7 @@ function isManagedSleepJob(
);
}
async function resolveManagedSleepCronStatus(params: {
async function resolveManagedDreamingCronStatus(params: {
context: {
cron?: { list?: (opts?: { includeDisabled?: boolean }) => Promise<unknown[]> };
};
@@ -332,7 +332,7 @@ async function resolveManagedSleepCronStatus(params: {
tag: string;
payloadText: string;
};
}): Promise<ManagedSleepCronStatus> {
}): Promise<ManagedDreamingCronStatus> {
if (!params.context.cron || typeof params.context.cron.list !== "function") {
return { managedCronPresent: false };
}
@@ -340,7 +340,7 @@ async function resolveManagedSleepCronStatus(params: {
const jobs = await params.context.cron.list({ includeDisabled: true });
const managed = jobs
.filter((job): job is ManagedCronJobLike => typeof job === "object" && job !== null)
.filter((job) => isManagedSleepJob(job, params.match));
.filter((job) => isManagedDreamingJob(job, params.match));
let nextRunAtMs: number | undefined;
for (const job of managed) {
if (job.enabled !== true) {
@@ -363,11 +363,11 @@ async function resolveManagedSleepCronStatus(params: {
}
}
async function resolveAllManagedSleepCronStatuses(context: {
async function resolveAllManagedDreamingCronStatuses(context: {
cron?: { list?: (opts?: { includeDisabled?: boolean }) => Promise<unknown[]> };
}): Promise<Record<"light" | "deep" | "rem", ManagedSleepCronStatus>> {
}): Promise<Record<"light" | "deep" | "rem", ManagedDreamingCronStatus>> {
return {
light: await resolveManagedSleepCronStatus({
light: await resolveManagedDreamingCronStatus({
context,
match: {
name: MANAGED_LIGHT_SLEEP_CRON_NAME,
@@ -375,7 +375,7 @@ async function resolveAllManagedSleepCronStatuses(context: {
payloadText: LIGHT_SLEEP_SYSTEM_EVENT_TEXT,
},
}),
deep: await resolveManagedSleepCronStatus({
deep: await resolveManagedDreamingCronStatus({
context,
match: {
name: MANAGED_DEEP_SLEEP_CRON_NAME,
@@ -383,7 +383,7 @@ async function resolveAllManagedSleepCronStatuses(context: {
payloadText: DEEP_SLEEP_SYSTEM_EVENT_TEXT,
},
}),
rem: await resolveManagedSleepCronStatus({
rem: await resolveManagedDreamingCronStatus({
context,
match: {
name: MANAGED_REM_SLEEP_CRON_NAME,
@@ -422,19 +422,19 @@ export const doctorHandlers: GatewayRequestHandlers = {
embedding = { ok: false, error: "memory embeddings unavailable" };
}
const nowMs = Date.now();
const sleepConfig = resolveSleepConfig(cfg);
const dreamingConfig = resolveDreamingConfig(cfg);
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
const configuredWorkspaces = resolveMemorySleepWorkspaces(cfg).map(
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg).map(
(entry) => entry.workspaceDir,
);
const allWorkspaces =
configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : [];
const storeStats =
allWorkspaces.length > 0
? mergeSleepStoreStats(
? mergeDreamingStoreStats(
await Promise.all(
allWorkspaces.map((entry) =>
loadSleepStoreStats(entry, nowMs, sleepConfig.timezone),
loadDreamingStoreStats(entry, nowMs, dreamingConfig.timezone),
),
),
)
@@ -443,25 +443,25 @@ export const doctorHandlers: GatewayRequestHandlers = {
promotedTotal: 0,
promotedToday: 0,
};
const cronStatuses = await resolveAllManagedSleepCronStatuses(context);
const cronStatuses = await resolveAllManagedDreamingCronStatuses(context);
const payload: DoctorMemoryStatusPayload = {
agentId,
provider: status.provider,
embedding,
sleep: {
...sleepConfig,
dreaming: {
...dreamingConfig,
...storeStats,
phases: {
light: {
...sleepConfig.phases.light,
...dreamingConfig.phases.light,
...cronStatuses.light,
},
deep: {
...sleepConfig.phases.deep,
...dreamingConfig.phases.deep,
...cronStatuses.deep,
},
rem: {
...sleepConfig.phases.rem,
...dreamingConfig.phases.rem,
...cronStatuses.rem,
},
},

View File

@@ -1,168 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main"));
const resolveAgentWorkspaceDir = vi.hoisted(() =>
vi.fn((_cfg: OpenClawConfig, agentId: string) => `/workspace/${agentId}`),
);
const resolveMemorySearchConfig = vi.hoisted(() =>
vi.fn<(_cfg: OpenClawConfig, _agentId: string) => { enabled: boolean } | null>(() => ({
enabled: true,
})),
);
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
resolveAgentWorkspaceDir,
}));
vi.mock("../agents/memory-search.js", () => ({
resolveMemorySearchConfig,
}));
import {
formatMemorySleepDay,
isSameMemorySleepDay,
resolveMemoryCorePluginConfig,
resolveMemorySleepConfig,
resolveMemorySleepWorkspaces,
} from "./sleep.js";
describe("memory sleep host helpers", () => {
it("normalizes string settings from the sleep config", () => {
const resolved = resolveMemorySleepConfig({
pluginConfig: {
sleep: {
enabled: true,
timezone: "Europe/London",
storage: {
mode: "both",
separateReports: true,
},
phases: {
deep: {
cron: "0 */4 * * *",
limit: "5",
minScore: "0.9",
minRecallCount: "4",
minUniqueQueries: "2",
recencyHalfLifeDays: "21",
maxAgeDays: "30",
},
},
},
},
});
expect(resolved.enabled).toBe(true);
expect(resolved.timezone).toBe("Europe/London");
expect(resolved.storage).toEqual({
mode: "both",
separateReports: true,
});
expect(resolved.phases.deep).toMatchObject({
cron: "0 */4 * * *",
limit: 5,
minScore: 0.9,
minRecallCount: 4,
minUniqueQueries: 2,
recencyHalfLifeDays: 21,
maxAgeDays: 30,
});
});
it("falls back to cfg timezone and deep defaults", () => {
const cfg = {
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
} as OpenClawConfig;
const resolved = resolveMemorySleepConfig({
pluginConfig: {},
cfg,
});
expect(resolved.enabled).toBe(true);
expect(resolved.timezone).toBe("America/Los_Angeles");
expect(resolved.phases.deep).toMatchObject({
cron: "0 3 * * *",
limit: 10,
minScore: 0.8,
recencyHalfLifeDays: 14,
maxAgeDays: 30,
});
});
it("dedupes shared workspaces and skips agents without memory search", () => {
resolveMemorySearchConfig.mockImplementation((_cfg: OpenClawConfig, agentId: string) =>
agentId === "beta" ? null : { enabled: true },
);
resolveAgentWorkspaceDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) => {
if (agentId === "alpha") {
return "/workspace/shared";
}
if (agentId === "gamma") {
return "/workspace/shared";
}
return `/workspace/${agentId}`;
});
const cfg = {
agents: {
list: [{ id: "alpha" }, { id: "beta" }, { id: "gamma" }],
},
} as OpenClawConfig;
expect(resolveMemorySleepWorkspaces(cfg)).toEqual([
{
workspaceDir: "/workspace/shared",
agentIds: ["alpha", "gamma"],
},
]);
});
it("uses default agent fallback and timezone-aware day helpers", () => {
resolveDefaultAgentId.mockReturnValue("fallback");
const cfg = {} as OpenClawConfig;
expect(resolveMemorySleepWorkspaces(cfg)).toEqual([
{
workspaceDir: "/workspace/fallback",
agentIds: ["fallback"],
},
]);
expect(
formatMemorySleepDay(Date.parse("2026-04-02T06:30:00.000Z"), "America/Los_Angeles"),
).toBe("2026-04-01");
expect(
isSameMemorySleepDay(
Date.parse("2026-04-02T06:30:00.000Z"),
Date.parse("2026-04-02T06:50:00.000Z"),
"America/Los_Angeles",
),
).toBe(true);
expect(
resolveMemoryCorePluginConfig({
plugins: {
entries: {
"memory-core": {
config: {
sleep: {
enabled: true,
},
},
},
},
},
} as OpenClawConfig),
).toEqual({
sleep: {
enabled: true,
},
});
});
});

View File

@@ -1,603 +0,0 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { OpenClawConfig } from "../config/config.js";
export const DEFAULT_MEMORY_SLEEP_ENABLED = true;
export const DEFAULT_MEMORY_SLEEP_TIMEZONE = undefined;
export const DEFAULT_MEMORY_SLEEP_VERBOSE_LOGGING = false;
export const DEFAULT_MEMORY_SLEEP_STORAGE_MODE = "inline";
export const DEFAULT_MEMORY_SLEEP_SEPARATE_REPORTS = false;
export const DEFAULT_MEMORY_LIGHT_SLEEP_CRON_EXPR = "0 */6 * * *";
export const DEFAULT_MEMORY_LIGHT_SLEEP_LOOKBACK_DAYS = 2;
export const DEFAULT_MEMORY_LIGHT_SLEEP_LIMIT = 100;
export const DEFAULT_MEMORY_LIGHT_SLEEP_DEDUPE_SIMILARITY = 0.9;
export const DEFAULT_MEMORY_DEEP_SLEEP_CRON_EXPR = "0 3 * * *";
export const DEFAULT_MEMORY_DEEP_SLEEP_LIMIT = 10;
export const DEFAULT_MEMORY_DEEP_SLEEP_MIN_SCORE = 0.8;
export const DEFAULT_MEMORY_DEEP_SLEEP_MIN_RECALL_COUNT = 3;
export const DEFAULT_MEMORY_DEEP_SLEEP_MIN_UNIQUE_QUERIES = 3;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECENCY_HALF_LIFE_DAYS = 14;
export const DEFAULT_MEMORY_DEEP_SLEEP_MAX_AGE_DAYS = 30;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_ENABLED = true;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_TRIGGER_BELOW_HEALTH = 0.35;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_LOOKBACK_DAYS = 30;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_MAX_CANDIDATES = 20;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_MIN_CONFIDENCE = 0.9;
export const DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_AUTO_WRITE_MIN_CONFIDENCE = 0.97;
export const DEFAULT_MEMORY_REM_SLEEP_CRON_EXPR = "0 5 * * 0";
export const DEFAULT_MEMORY_REM_SLEEP_LOOKBACK_DAYS = 7;
export const DEFAULT_MEMORY_REM_SLEEP_LIMIT = 10;
export const DEFAULT_MEMORY_REM_SLEEP_MIN_PATTERN_STRENGTH = 0.75;
export const DEFAULT_MEMORY_SLEEP_SPEED = "balanced";
export const DEFAULT_MEMORY_SLEEP_THINKING = "medium";
export const DEFAULT_MEMORY_SLEEP_BUDGET = "medium";
export type MemorySleepSpeed = "fast" | "balanced" | "slow";
export type MemorySleepThinking = "low" | "medium" | "high";
export type MemorySleepBudget = "cheap" | "medium" | "expensive";
export type MemorySleepStorageMode = "inline" | "separate" | "both";
export type MemoryLightSleepSource = "daily" | "sessions" | "recall";
export type MemoryDeepSleepSource = "daily" | "memory" | "sessions" | "logs" | "recall";
export type MemoryRemSleepSource = "memory" | "daily" | "deep";
export type MemorySleepExecutionConfig = {
speed: MemorySleepSpeed;
thinking: MemorySleepThinking;
budget: MemorySleepBudget;
model?: string;
maxOutputTokens?: number;
temperature?: number;
timeoutMs?: number;
};
export type MemorySleepStorageConfig = {
mode: MemorySleepStorageMode;
separateReports: boolean;
};
export type MemoryLightSleepConfig = {
enabled: boolean;
cron: string;
lookbackDays: number;
limit: number;
dedupeSimilarity: number;
sources: MemoryLightSleepSource[];
execution: MemorySleepExecutionConfig;
};
export type MemoryDeepSleepRecoveryConfig = {
enabled: boolean;
triggerBelowHealth: number;
lookbackDays: number;
maxRecoveredCandidates: number;
minRecoveryConfidence: number;
autoWriteMinConfidence: number;
};
export type MemoryDeepSleepConfig = {
enabled: boolean;
cron: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
recencyHalfLifeDays: number;
maxAgeDays?: number;
sources: MemoryDeepSleepSource[];
recovery: MemoryDeepSleepRecoveryConfig;
execution: MemorySleepExecutionConfig;
};
export type MemoryRemSleepConfig = {
enabled: boolean;
cron: string;
lookbackDays: number;
limit: number;
minPatternStrength: number;
sources: MemoryRemSleepSource[];
execution: MemorySleepExecutionConfig;
};
export type MemorySleepPhaseName = "light" | "deep" | "rem";
export type MemorySleepConfig = {
enabled: boolean;
timezone?: string;
verboseLogging: boolean;
storage: MemorySleepStorageConfig;
execution: {
defaults: MemorySleepExecutionConfig;
};
phases: {
light: MemoryLightSleepConfig;
deep: MemoryDeepSleepConfig;
rem: MemoryRemSleepConfig;
};
};
export type MemorySleepWorkspace = {
workspaceDir: string;
agentIds: string[];
};
const DEFAULT_MEMORY_LIGHT_SLEEP_SOURCES: MemoryLightSleepSource[] = [
"daily",
"sessions",
"recall",
];
const DEFAULT_MEMORY_DEEP_SLEEP_SOURCES: MemoryDeepSleepSource[] = [
"daily",
"memory",
"sessions",
"logs",
"recall",
];
const DEFAULT_MEMORY_REM_SLEEP_SOURCES: MemoryRemSleepSource[] = ["memory", "daily", "deep"];
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 normalizeNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value === "string" && value.trim().length === 0) {
return fallback;
}
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
if (!Number.isFinite(num)) {
return fallback;
}
const floored = Math.floor(num);
if (floored < 0) {
return fallback;
}
return floored;
}
function normalizeOptionalPositiveInt(value: unknown): number | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string" && value.trim().length === 0) {
return undefined;
}
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
if (!Number.isFinite(num)) {
return undefined;
}
const floored = Math.floor(num);
if (floored <= 0) {
return undefined;
}
return floored;
}
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") {
return true;
}
if (normalized === "false") {
return false;
}
}
return fallback;
}
function normalizeScore(value: unknown, fallback: number): number {
if (typeof value === "string" && value.trim().length === 0) {
return fallback;
}
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
if (!Number.isFinite(num) || num < 0 || num > 1) {
return fallback;
}
return num;
}
function normalizeSimilarity(value: unknown, fallback: number): number {
return normalizeScore(value, fallback);
}
function normalizeStringArray<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: readonly T[],
): T[] {
if (!Array.isArray(value)) {
return [...fallback];
}
const allowedSet = new Set(allowed);
const normalized: T[] = [];
for (const entry of value) {
const normalizedEntry = normalizeTrimmedString(entry)?.toLowerCase();
if (!normalizedEntry || !allowedSet.has(normalizedEntry as T)) {
continue;
}
if (!normalized.includes(normalizedEntry as T)) {
normalized.push(normalizedEntry as T);
}
}
return normalized.length > 0 ? normalized : [...fallback];
}
function normalizeStorageMode(value: unknown): MemorySleepStorageMode {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (normalized === "inline" || normalized === "separate" || normalized === "both") {
return normalized;
}
return DEFAULT_MEMORY_SLEEP_STORAGE_MODE;
}
function normalizeSpeed(value: unknown): MemorySleepSpeed | undefined {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (normalized === "fast" || normalized === "balanced" || normalized === "slow") {
return normalized;
}
return undefined;
}
function normalizeThinking(value: unknown): MemorySleepThinking | undefined {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return undefined;
}
function normalizeBudget(value: unknown): MemorySleepBudget | undefined {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (normalized === "cheap" || normalized === "medium" || normalized === "expensive") {
return normalized;
}
return undefined;
}
function resolveExecutionConfig(
value: unknown,
fallback: MemorySleepExecutionConfig,
): MemorySleepExecutionConfig {
const record = asRecord(value);
const maxOutputTokens = normalizeOptionalPositiveInt(record?.maxOutputTokens);
const timeoutMs = normalizeOptionalPositiveInt(record?.timeoutMs);
const temperatureRaw = record?.temperature;
const temperature =
typeof temperatureRaw === "number" && Number.isFinite(temperatureRaw) && temperatureRaw >= 0
? Math.min(2, temperatureRaw)
: undefined;
return {
speed: normalizeSpeed(record?.speed) ?? fallback.speed,
thinking: normalizeThinking(record?.thinking) ?? fallback.thinking,
budget: normalizeBudget(record?.budget) ?? fallback.budget,
...(normalizeTrimmedString(record?.model)
? { model: normalizeTrimmedString(record?.model) }
: {}),
...(typeof maxOutputTokens === "number" ? { maxOutputTokens } : {}),
...(typeof temperature === "number" ? { temperature } : {}),
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
};
}
function normalizePathForComparison(input: string): string {
const normalized = path.resolve(input);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
function formatLocalIsoDay(epochMs: number): string {
const date = new Date(epochMs);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function resolveMemoryCorePluginConfig(
cfg: OpenClawConfig | Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const root = asRecord(cfg);
const plugins = asRecord(root?.plugins);
const entries = asRecord(plugins?.entries);
const memoryCore = asRecord(entries?.["memory-core"]);
return asRecord(memoryCore?.config) ?? undefined;
}
export function resolveMemorySleepConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): MemorySleepConfig {
const sleep = asRecord(params.pluginConfig?.sleep);
const timezone =
normalizeTrimmedString(sleep?.timezone) ??
normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone) ??
DEFAULT_MEMORY_SLEEP_TIMEZONE;
const storage = asRecord(sleep?.storage);
const execution = asRecord(sleep?.execution);
const phases = asRecord(sleep?.phases);
const defaultExecution = resolveExecutionConfig(execution?.defaults, {
speed: DEFAULT_MEMORY_SLEEP_SPEED,
thinking: DEFAULT_MEMORY_SLEEP_THINKING,
budget: DEFAULT_MEMORY_SLEEP_BUDGET,
});
const light = asRecord(phases?.light);
const deep = asRecord(phases?.deep);
const rem = asRecord(phases?.rem);
const deepRecovery = asRecord(deep?.recovery);
const maxAgeDays = normalizeOptionalPositiveInt(deep?.maxAgeDays);
return {
enabled: normalizeBoolean(sleep?.enabled, DEFAULT_MEMORY_SLEEP_ENABLED),
...(timezone ? { timezone } : {}),
verboseLogging: normalizeBoolean(sleep?.verboseLogging, DEFAULT_MEMORY_SLEEP_VERBOSE_LOGGING),
storage: {
mode: normalizeStorageMode(storage?.mode),
separateReports: normalizeBoolean(
storage?.separateReports,
DEFAULT_MEMORY_SLEEP_SEPARATE_REPORTS,
),
},
execution: {
defaults: defaultExecution,
},
phases: {
light: {
enabled: normalizeBoolean(light?.enabled, true),
cron: normalizeTrimmedString(light?.cron) ?? DEFAULT_MEMORY_LIGHT_SLEEP_CRON_EXPR,
lookbackDays: normalizeNonNegativeInt(
light?.lookbackDays,
DEFAULT_MEMORY_LIGHT_SLEEP_LOOKBACK_DAYS,
),
limit: normalizeNonNegativeInt(light?.limit, DEFAULT_MEMORY_LIGHT_SLEEP_LIMIT),
dedupeSimilarity: normalizeSimilarity(
light?.dedupeSimilarity,
DEFAULT_MEMORY_LIGHT_SLEEP_DEDUPE_SIMILARITY,
),
sources: normalizeStringArray(
light?.sources,
["daily", "sessions", "recall"] as const,
DEFAULT_MEMORY_LIGHT_SLEEP_SOURCES,
),
execution: resolveExecutionConfig(light?.execution, {
...defaultExecution,
speed: "fast",
thinking: "low",
budget: "cheap",
}),
},
deep: {
enabled: normalizeBoolean(deep?.enabled, true),
cron: normalizeTrimmedString(deep?.cron) ?? DEFAULT_MEMORY_DEEP_SLEEP_CRON_EXPR,
limit: normalizeNonNegativeInt(deep?.limit, DEFAULT_MEMORY_DEEP_SLEEP_LIMIT),
minScore: normalizeScore(deep?.minScore, DEFAULT_MEMORY_DEEP_SLEEP_MIN_SCORE),
minRecallCount: normalizeNonNegativeInt(
deep?.minRecallCount,
DEFAULT_MEMORY_DEEP_SLEEP_MIN_RECALL_COUNT,
),
minUniqueQueries: normalizeNonNegativeInt(
deep?.minUniqueQueries,
DEFAULT_MEMORY_DEEP_SLEEP_MIN_UNIQUE_QUERIES,
),
recencyHalfLifeDays: normalizeNonNegativeInt(
deep?.recencyHalfLifeDays,
DEFAULT_MEMORY_DEEP_SLEEP_RECENCY_HALF_LIFE_DAYS,
),
...(typeof maxAgeDays === "number"
? { maxAgeDays }
: typeof DEFAULT_MEMORY_DEEP_SLEEP_MAX_AGE_DAYS === "number"
? { maxAgeDays: DEFAULT_MEMORY_DEEP_SLEEP_MAX_AGE_DAYS }
: {}),
sources: normalizeStringArray(
deep?.sources,
["daily", "memory", "sessions", "logs", "recall"] as const,
DEFAULT_MEMORY_DEEP_SLEEP_SOURCES,
),
recovery: {
enabled: normalizeBoolean(
deepRecovery?.enabled,
DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_ENABLED,
),
triggerBelowHealth: normalizeScore(
deepRecovery?.triggerBelowHealth,
DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_TRIGGER_BELOW_HEALTH,
),
lookbackDays: normalizeNonNegativeInt(
deepRecovery?.lookbackDays,
DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_LOOKBACK_DAYS,
),
maxRecoveredCandidates: normalizeNonNegativeInt(
deepRecovery?.maxRecoveredCandidates,
DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_MAX_CANDIDATES,
),
minRecoveryConfidence: normalizeScore(
deepRecovery?.minRecoveryConfidence,
DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_MIN_CONFIDENCE,
),
autoWriteMinConfidence: normalizeScore(
deepRecovery?.autoWriteMinConfidence,
DEFAULT_MEMORY_DEEP_SLEEP_RECOVERY_AUTO_WRITE_MIN_CONFIDENCE,
),
},
execution: resolveExecutionConfig(deep?.execution, {
...defaultExecution,
speed: "balanced",
thinking: "high",
budget: "medium",
}),
},
rem: {
enabled: normalizeBoolean(rem?.enabled, true),
cron: normalizeTrimmedString(rem?.cron) ?? DEFAULT_MEMORY_REM_SLEEP_CRON_EXPR,
lookbackDays: normalizeNonNegativeInt(
rem?.lookbackDays,
DEFAULT_MEMORY_REM_SLEEP_LOOKBACK_DAYS,
),
limit: normalizeNonNegativeInt(rem?.limit, DEFAULT_MEMORY_REM_SLEEP_LIMIT),
minPatternStrength: normalizeScore(
rem?.minPatternStrength,
DEFAULT_MEMORY_REM_SLEEP_MIN_PATTERN_STRENGTH,
),
sources: normalizeStringArray(
rem?.sources,
["memory", "daily", "deep"] as const,
DEFAULT_MEMORY_REM_SLEEP_SOURCES,
),
execution: resolveExecutionConfig(rem?.execution, {
...defaultExecution,
speed: "slow",
thinking: "high",
budget: "expensive",
}),
},
},
};
}
export function resolveMemoryDeepSleepConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): MemoryDeepSleepConfig & {
timezone?: string;
verboseLogging: boolean;
storage: MemorySleepStorageConfig;
} {
const resolved = resolveMemorySleepConfig(params);
return {
...resolved.phases.deep,
enabled: resolved.enabled && resolved.phases.deep.enabled,
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
verboseLogging: resolved.verboseLogging,
storage: resolved.storage,
};
}
export function resolveMemoryLightSleepConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): MemoryLightSleepConfig & {
timezone?: string;
verboseLogging: boolean;
storage: MemorySleepStorageConfig;
} {
const resolved = resolveMemorySleepConfig(params);
return {
...resolved.phases.light,
enabled: resolved.enabled && resolved.phases.light.enabled,
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
verboseLogging: resolved.verboseLogging,
storage: resolved.storage,
};
}
export function resolveMemoryRemSleepConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): MemoryRemSleepConfig & {
timezone?: string;
verboseLogging: boolean;
storage: MemorySleepStorageConfig;
} {
const resolved = resolveMemorySleepConfig(params);
return {
...resolved.phases.rem,
enabled: resolved.enabled && resolved.phases.rem.enabled,
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
verboseLogging: resolved.verboseLogging,
storage: resolved.storage,
};
}
export function formatMemorySleepDay(epochMs: number, timezone?: string): string {
if (!timezone) {
return formatLocalIsoDay(epochMs);
}
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(epochMs));
const values = new Map(parts.map((part) => [part.type, part.value]));
const year = values.get("year");
const month = values.get("month");
const day = values.get("day");
if (year && month && day) {
return `${year}-${month}-${day}`;
}
} catch {
// Fall back to host-local day for invalid or unsupported timezones.
}
return formatLocalIsoDay(epochMs);
}
export function isSameMemorySleepDay(
firstEpochMs: number,
secondEpochMs: number,
timezone?: string,
): boolean {
return (
formatMemorySleepDay(firstEpochMs, timezone) === formatMemorySleepDay(secondEpochMs, timezone)
);
}
export function resolveMemorySleepWorkspaces(cfg: OpenClawConfig): MemorySleepWorkspace[] {
const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const agentIds: string[] = [];
const seenAgents = new Set<string>();
for (const entry of configured) {
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
continue;
}
const id = entry.id.trim().toLowerCase();
if (!id || seenAgents.has(id)) {
continue;
}
seenAgents.add(id);
agentIds.push(id);
}
if (agentIds.length === 0) {
agentIds.push(resolveDefaultAgentId(cfg));
}
const byWorkspace = new Map<string, MemorySleepWorkspace>();
for (const agentId of agentIds) {
if (!resolveMemorySearchConfig(cfg, agentId)) {
continue;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId)?.trim();
if (!workspaceDir) {
continue;
}
const key = normalizePathForComparison(workspaceDir);
const existing = byWorkspace.get(key);
if (existing) {
existing.agentIds.push(agentId);
continue;
}
byWorkspace.set(key, { workspaceDir, agentIds: [agentId] });
}
return [...byWorkspace.values()];
}

View File

@@ -1,2 +1,2 @@
export * from "../memory-host-sdk/status.js";
export * from "../memory-host-sdk/sleep.js";
export * from "../memory-host-sdk/dreaming.js";

View File

@@ -48,11 +48,11 @@ export {
} from "./memory-core-host-runtime-cli.js";
export {
resolveMemoryCorePluginConfig,
formatMemorySleepDay,
isSameMemorySleepDay,
resolveMemoryDeepSleepConfig,
resolveMemorySleepConfig,
resolveMemorySleepWorkspaces,
formatMemoryDreamingDay,
isSameMemoryDreamingDay,
resolveMemoryDeepDreamingConfig,
resolveMemoryDreamingConfig,
resolveMemoryDreamingWorkspaces,
} from "./memory-core-host-status.js";
export {
listMemoryFiles,

View File

@@ -45,7 +45,7 @@ export const en: TranslationMap = {
aiAgents: "AI & Agents",
debug: "Debug",
logs: "Logs",
dreams: "Dreams",
dreams: "Dreaming",
},
subtitles: {
agents: "Workspaces, tools, identities.",
@@ -66,7 +66,7 @@ export const en: TranslationMap = {
aiAgents: "Agents, models, skills, tools, memory, session.",
debug: "Snapshots, events, RPC.",
logs: "Live gateway logs.",
dreams: "Memory consolidation while sleeping.",
dreams: "Memory dreaming, consolidation, and reflection.",
},
overview: {
access: {

View File

@@ -66,9 +66,9 @@ import {
} from "./controllers/devices.ts";
import {
loadDreamingStatus,
updateSleepEnabled,
updateSleepPhaseEnabled,
type SleepPhaseId,
updateDreamingEnabled,
updateDreamingPhaseEnabled,
type DreamingPhaseId,
} from "./controllers/dreaming.ts";
import {
loadExecApprovals,
@@ -149,16 +149,16 @@ const lazyLogs = createLazy(() => import("./views/logs.ts"));
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 SLEEP_PHASE_OPTIONS: Array<{ id: SleepPhaseId; label: string; detail: string }> = [
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" },
];
function resolveConfiguredSleep(configValue: Record<string, unknown> | null): {
function resolveConfiguredDreaming(configValue: Record<string, unknown> | null): {
enabled: boolean;
phases: Record<SleepPhaseId, boolean>;
phases: Record<DreamingPhaseId, boolean>;
} {
if (!configValue) {
return {
@@ -174,13 +174,13 @@ function resolveConfiguredSleep(configValue: Record<string, unknown> | null): {
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 sleep = config?.sleep as Record<string, unknown> | undefined;
const phases = sleep?.phases 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;
return {
enabled: typeof sleep?.enabled === "boolean" ? sleep.enabled : true,
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,
@@ -199,8 +199,8 @@ function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
});
}
function resolveSleepNextCycle(
status: { phases: Record<SleepPhaseId, { enabled: boolean; nextRunAtMs?: number }> } | null,
function resolveDreamingNextCycle(
status: { phases: Record<DreamingPhaseId, { enabled: boolean; nextRunAtMs?: number }> } | null,
): string | null {
if (!status) {
return null;
@@ -397,17 +397,17 @@ export function renderApp(state: AppViewState) {
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
const configValue =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const configuredSleep = resolveConfiguredSleep(configValue);
const dreamingOn = state.dreamingStatus?.enabled ?? configuredSleep.enabled;
const dreamingNextCycle = resolveSleepNextCycle(state.dreamingStatus);
const configuredDreaming = resolveConfiguredDreaming(configValue);
const dreamingOn = state.dreamingStatus?.enabled ?? configuredDreaming.enabled;
const dreamingNextCycle = resolveDreamingNextCycle(state.dreamingStatus);
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
const refreshDreamingStatus = () => loadDreamingStatus(state);
const applySleepEnabled = (enabled: boolean) => {
const applyDreamingEnabled = (enabled: boolean) => {
if (state.dreamingModeSaving || dreamingOn === enabled) {
return;
}
void (async () => {
const updated = await updateSleepEnabled(state, enabled);
const updated = await updateDreamingEnabled(state, enabled);
if (!updated) {
return;
}
@@ -415,17 +415,17 @@ export function renderApp(state: AppViewState) {
await loadDreamingStatus(state);
})();
};
const applySleepPhaseEnabled = (phase: SleepPhaseId, enabled: boolean) => {
const applyDreamingPhaseEnabled = (phase: DreamingPhaseId, enabled: boolean) => {
if (state.dreamingModeSaving) {
return;
}
const currentEnabled =
state.dreamingStatus?.phases[phase].enabled ?? configuredSleep.phases[phase];
state.dreamingStatus?.phases[phase].enabled ?? configuredDreaming.phases[phase];
if (currentEnabled === enabled) {
return;
}
void (async () => {
const updated = await updateSleepPhaseEnabled(state, phase, enabled);
const updated = await updateDreamingPhaseEnabled(state, phase, enabled);
if (!updated) {
return;
}
@@ -740,23 +740,19 @@ export function renderApp(state: AppViewState) {
<div
class="dreaming-header-controls__modes"
role="group"
aria-label="Sleep controls"
aria-label="Dreaming controls"
>
<button
class="dreaming-header-controls__mode ${dreamingOn
? "dreaming-header-controls__mode--active"
: ""}"
?disabled=${dreamingLoading}
title=${dreamingOn
? "Sleep maintenance is enabled."
: "Sleep maintenance is disabled."}
aria-label=${dreamingOn
? "Disable sleep maintenance"
: "Enable sleep maintenance"}
@click=${() => applySleepEnabled(!dreamingOn)}
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 ? "Sleep On" : "Sleep Off"}</span
>${dreamingOn ? "Dreaming On" : "Dreaming Off"}</span
>
<span class="dreaming-header-controls__mode-detail"
>${dreamingOn ? "all phases may run" : "no phases will run"}</span
@@ -2149,8 +2145,8 @@ export function renderApp(state: AppViewState) {
)
: nothing}
${state.tab === "dreams"
? lazyRender(lazyDreams, (m) =>
m.renderDreams({
? lazyRender(lazyDreamingView, (m) =>
m.renderDreaming({
active: dreamingOn,
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
longTermCount: state.dreamingStatus?.promotedTotal ?? 0,
@@ -2158,11 +2154,11 @@ export function renderApp(state: AppViewState) {
dreamingOf: null,
nextCycle: dreamingNextCycle,
timezone: state.dreamingStatus?.timezone ?? null,
phases: SLEEP_PHASE_OPTIONS.map((phase) => ({
phases: DREAMING_PHASE_OPTIONS.map((phase) => ({
...phase,
enabled:
state.dreamingStatus?.phases[phase.id].enabled ??
configuredSleep.phases[phase.id],
configuredDreaming.phases[phase.id],
nextCycle: formatDreamNextCycle(
state.dreamingStatus?.phases[phase.id].nextRunAtMs,
),
@@ -2173,8 +2169,8 @@ export function renderApp(state: AppViewState) {
statusError: state.dreamingStatusError,
modeSaving: state.dreamingModeSaving,
onRefresh: refreshDreamingStatus,
onToggleEnabled: applySleepEnabled,
onTogglePhase: applySleepPhaseEnabled,
onToggleEnabled: applyDreamingEnabled,
onTogglePhase: applyDreamingPhaseEnabled,
}),
)
: nothing}

View File

@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import {
loadDreamingStatus,
updateSleepEnabled,
updateSleepPhaseEnabled,
updateDreamingEnabled,
updateDreamingPhaseEnabled,
type DreamingState,
} from "./dreaming.ts";
@@ -24,11 +24,11 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
return { state, request };
}
describe("sleep controller", () => {
it("loads and normalizes sleep status from doctor.memory.status", async () => {
describe("dreaming controller", () => {
it("loads and normalizes dreaming status from doctor.memory.status", async () => {
const { state, request } = createState();
request.mockResolvedValue({
sleep: {
dreaming: {
enabled: true,
timezone: "America/Los_Angeles",
verboseLogging: false,
@@ -91,11 +91,11 @@ describe("sleep controller", () => {
expect(state.dreamingStatusError).toBeNull();
});
it("patches config to update global sleep enablement", async () => {
it("patches config to update global dreaming enablement", async () => {
const { state, request } = createState();
request.mockResolvedValue({ ok: true });
const ok = await updateSleepEnabled(state, false);
const ok = await updateDreamingEnabled(state, false);
expect(ok).toBe(true);
expect(request).toHaveBeenCalledWith(
@@ -113,7 +113,7 @@ describe("sleep controller", () => {
const { state, request } = createState();
request.mockResolvedValue({ ok: true });
const ok = await updateSleepPhaseEnabled(state, "rem", false);
const ok = await updateDreamingPhaseEnabled(state, "rem", false);
expect(ok).toBe(true);
expect(request).toHaveBeenCalledWith(
@@ -128,7 +128,7 @@ describe("sleep controller", () => {
const { state, request } = createState();
state.configSnapshot = {};
const ok = await updateSleepEnabled(state, true);
const ok = await updateDreamingEnabled(state, true);
expect(ok).toBe(false);
expect(request).not.toHaveBeenCalled();

View File

@@ -1,21 +1,21 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ConfigSnapshot } from "../types.ts";
export type SleepPhaseId = "light" | "deep" | "rem";
export type DreamingPhaseId = "light" | "deep" | "rem";
type SleepPhaseStatusBase = {
type DreamingPhaseStatusBase = {
enabled: boolean;
cron: string;
managedCronPresent: boolean;
nextRunAtMs?: number;
};
type LightSleepStatus = SleepPhaseStatusBase & {
type LightDreamingStatus = DreamingPhaseStatusBase & {
lookbackDays: number;
limit: number;
};
type DeepSleepStatus = SleepPhaseStatusBase & {
type DeepDreamingStatus = DreamingPhaseStatusBase & {
limit: number;
minScore: number;
minRecallCount: number;
@@ -24,13 +24,13 @@ type DeepSleepStatus = SleepPhaseStatusBase & {
maxAgeDays?: number;
};
type RemSleepStatus = SleepPhaseStatusBase & {
type RemDreamingStatus = DreamingPhaseStatusBase & {
lookbackDays: number;
limit: number;
minPatternStrength: number;
};
export type SleepStatus = {
export type DreamingStatus = {
enabled: boolean;
timezone?: string;
verboseLogging: boolean;
@@ -42,14 +42,14 @@ export type SleepStatus = {
storePath?: string;
storeError?: string;
phases: {
light: LightSleepStatus;
deep: DeepSleepStatus;
rem: RemSleepStatus;
light: LightDreamingStatus;
deep: DeepDreamingStatus;
rem: RemDreamingStatus;
};
};
type DoctorMemoryStatusPayload = {
sleep?: unknown;
dreaming?: unknown;
};
export type DreamingState = {
@@ -59,7 +59,7 @@ export type DreamingState = {
applySessionKey: string;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: SleepStatus | null;
dreamingStatus: DreamingStatus | null;
dreamingModeSaving: boolean;
lastError: string | null;
};
@@ -97,7 +97,7 @@ function normalizeFiniteScore(value: unknown, fallback = 0): number {
return Math.max(0, Math.min(1, value));
}
function normalizeStorageMode(value: unknown): SleepStatus["storageMode"] {
function normalizeStorageMode(value: unknown): DreamingStatus["storageMode"] {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (normalized === "inline" || normalized === "separate" || normalized === "both") {
return normalized;
@@ -109,7 +109,7 @@ function normalizeNextRun(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizePhaseStatusBase(record: Record<string, unknown> | null): SleepPhaseStatusBase {
function normalizePhaseStatusBase(record: Record<string, unknown> | null): DreamingPhaseStatusBase {
return {
enabled: normalizeBoolean(record?.enabled, false),
cron: normalizeTrimmedString(record?.cron) ?? "",
@@ -120,7 +120,7 @@ function normalizePhaseStatusBase(record: Record<string, unknown> | null): Sleep
};
}
function normalizeSleepStatus(raw: unknown): SleepStatus | null {
function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
const record = asRecord(raw);
if (!record) {
return null;
@@ -182,7 +182,7 @@ export async function loadDreamingStatus(state: DreamingState): Promise<void> {
"doctor.memory.status",
{},
);
state.dreamingStatus = normalizeSleepStatus(payload?.sleep);
state.dreamingStatus = normalizeDreamingStatus(payload?.dreaming);
} catch (err) {
state.dreamingStatusError = String(err);
} finally {
@@ -190,7 +190,7 @@ export async function loadDreamingStatus(state: DreamingState): Promise<void> {
}
}
async function writeSleepPatch(
async function writeDreamingPatch(
state: DreamingState,
patch: Record<string, unknown>,
): Promise<boolean> {
@@ -213,7 +213,7 @@ async function writeSleepPatch(
baseHash,
raw: JSON.stringify(patch),
sessionKey: state.applySessionKey,
note: "Sleep settings updated from Dreams tab.",
note: "Dreaming settings updated from the Dreaming tab.",
});
return true;
} catch (err) {
@@ -226,13 +226,16 @@ async function writeSleepPatch(
}
}
export async function updateSleepEnabled(state: DreamingState, enabled: boolean): Promise<boolean> {
const ok = await writeSleepPatch(state, {
export async function updateDreamingEnabled(
state: DreamingState,
enabled: boolean,
): Promise<boolean> {
const ok = await writeDreamingPatch(state, {
plugins: {
entries: {
"memory-core": {
config: {
sleep: {
dreaming: {
enabled,
},
},
@@ -249,17 +252,17 @@ export async function updateSleepEnabled(state: DreamingState, enabled: boolean)
return ok;
}
export async function updateSleepPhaseEnabled(
export async function updateDreamingPhaseEnabled(
state: DreamingState,
phase: SleepPhaseId,
phase: DreamingPhaseId,
enabled: boolean,
): Promise<boolean> {
const ok = await writeSleepPatch(state, {
const ok = await writeDreamingPatch(state, {
plugins: {
entries: {
"memory-core": {
config: {
sleep: {
dreaming: {
phases: {
[phase]: {
enabled,
@@ -285,5 +288,3 @@ export async function updateSleepPhaseEnabled(
}
return ok;
}
export type DreamingStatus = SleepStatus;

View File

@@ -88,7 +88,7 @@ describe("control UI routing", () => {
const app = mountApp("/chat");
await app.updateComplete;
const dreamsLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/dreams"]');
const dreamsLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/dreaming"]');
expect(dreamsLink).not.toBeNull();
});

View File

@@ -63,7 +63,7 @@ const TAB_PATHS: Record<Tab, string> = {
aiAgents: "/ai-agents",
debug: "/debug",
logs: "/logs",
dreams: "/dreams",
dreams: "/dreaming",
};
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));

View File

@@ -1,151 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderDreams, type DreamsProps } from "./dreams.ts";
function buildProps(overrides?: Partial<DreamsProps>): DreamsProps {
return {
active: true,
shortTermCount: 47,
longTermCount: 182,
promotedCount: 12,
dreamingOf: null,
nextCycle: "4:00 AM",
timezone: "America/Los_Angeles",
phases: [
{
id: "light",
label: "Light",
detail: "sort and stage the day",
enabled: true,
nextCycle: "1:00 AM",
managedCronPresent: true,
},
{
id: "deep",
label: "Deep",
detail: "promote durable memory",
enabled: true,
nextCycle: "3:00 AM",
managedCronPresent: true,
},
{
id: "rem",
label: "REM",
detail: "surface themes and reflections",
enabled: false,
nextCycle: null,
managedCronPresent: false,
},
],
statusLoading: false,
statusError: null,
modeSaving: false,
onRefresh: () => {},
onToggleEnabled: () => {},
onTogglePhase: () => {},
...overrides,
};
}
function renderInto(props: DreamsProps): HTMLDivElement {
const container = document.createElement("div");
render(renderDreams(props), container);
return container;
}
describe("dreams view", () => {
it("renders the sleeping lobster SVG", () => {
const container = renderInto(buildProps());
const svg = container.querySelector(".dreams__lobster svg");
expect(svg).not.toBeNull();
});
it("shows three floating Z elements", () => {
const container = renderInto(buildProps());
const zs = container.querySelectorAll(".dreams__z");
expect(zs.length).toBe(3);
});
it("renders stars", () => {
const container = renderInto(buildProps());
const stars = container.querySelectorAll(".dreams__star");
expect(stars.length).toBe(12);
});
it("renders moon", () => {
const container = renderInto(buildProps());
expect(container.querySelector(".dreams__moon")).not.toBeNull();
});
it("displays memory stats", () => {
const container = renderInto(buildProps());
const values = container.querySelectorAll(".dreams__stat-value");
expect(values.length).toBe(3);
expect(values[0]?.textContent).toBe("47");
expect(values[1]?.textContent).toBe("182");
expect(values[2]?.textContent).toBe("12");
});
it("shows dream bubble when active", () => {
const container = renderInto(buildProps({ active: true }));
expect(container.querySelector(".dreams__bubble")).not.toBeNull();
});
it("hides dream bubble when idle", () => {
const container = renderInto(buildProps({ active: false }));
expect(container.querySelector(".dreams__bubble")).toBeNull();
});
it("shows custom dreamingOf text when provided", () => {
const container = renderInto(buildProps({ dreamingOf: "reindexing old chats…" }));
const text = container.querySelector(".dreams__bubble-text");
expect(text?.textContent).toBe("reindexing old chats…");
});
it("shows active status label when active", () => {
const container = renderInto(buildProps({ active: true }));
const label = container.querySelector(".dreams__status-label");
expect(label?.textContent).toBe("Sleep Maintenance 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("Sleep Idle");
});
it("applies idle class when not active", () => {
const container = renderInto(buildProps({ active: false }));
expect(container.querySelector(".dreams--idle")).not.toBeNull();
});
it("shows next cycle info when provided", () => {
const container = renderInto(buildProps({ nextCycle: "4:00 AM" }));
const detail = container.querySelector(".dreams__status-detail span");
expect(detail?.textContent).toContain("4:00 AM");
});
it("renders phase controls", () => {
const container = renderInto(buildProps());
expect(container.querySelector(".dreams__controls")).not.toBeNull();
expect(container.querySelectorAll(".dreams__phase").length).toBe(3);
});
it("renders control error when present", () => {
const container = renderInto(buildProps({ statusError: "patch failed" }));
expect(container.querySelector(".dreams__controls-error")?.textContent).toContain(
"patch failed",
);
});
it("wires phase toggle callbacks", () => {
const onTogglePhase = vi.fn();
const container = renderInto(buildProps({ onTogglePhase }));
container.querySelector<HTMLButtonElement>(".dreams__phase .btn")?.click();
expect(onTogglePhase).toHaveBeenCalled();
});
});

View File

@@ -1,256 +0,0 @@
import { html, nothing } from "lit";
import type { SleepPhaseId } from "../controllers/dreaming.ts";
export type DreamsProps = {
active: boolean;
shortTermCount: number;
longTermCount: number;
promotedCount: number;
dreamingOf: string | null;
nextCycle: string | null;
timezone: string | null;
phases: Array<{
id: SleepPhaseId;
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: SleepPhaseId, enabled: boolean) => 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…",
];
let _dreamIndex = Math.floor(Math.random() * DREAM_PHRASES.length);
let _dreamLastSwap = 0;
const DREAM_SWAP_MS = 6_000;
function currentDreamPhrase(): string {
const now = Date.now();
if (now - _dreamLastSwap > DREAM_SWAP_MS) {
_dreamLastSwap = now;
_dreamIndex = (_dreamIndex + 1) % DREAM_PHRASES.length;
}
return DREAM_PHRASES[_dreamIndex];
}
const STARS: {
top: number;
left: number;
size: number;
delay: number;
hue: "neutral" | "accent";
}[] = [
{ top: 8, left: 15, size: 3, delay: 0, hue: "neutral" },
{ top: 12, left: 72, size: 2, delay: 1.4, hue: "neutral" },
{ top: 22, left: 35, size: 3, delay: 0.6, hue: "accent" },
{ top: 18, left: 88, size: 2, delay: 2.1, hue: "neutral" },
{ top: 35, left: 8, size: 2, delay: 0.9, hue: "neutral" },
{ top: 45, left: 92, size: 2, delay: 1.7, hue: "neutral" },
{ top: 55, left: 25, size: 3, delay: 2.5, hue: "accent" },
{ top: 65, left: 78, size: 2, delay: 0.3, hue: "neutral" },
{ top: 75, left: 45, size: 2, delay: 1.1, hue: "neutral" },
{ top: 82, left: 60, size: 3, delay: 1.8, hue: "accent" },
{ top: 30, left: 55, size: 2, delay: 0.4, hue: "neutral" },
{ top: 88, left: 18, size: 2, delay: 2.3, hue: "neutral" },
];
const sleepingLobster = html`
<svg viewBox="0 0 120 120" fill="none">
<defs>
<linearGradient id="dream-lob-g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d" />
<stop offset="100%" stop-color="#991b1b" />
</linearGradient>
</defs>
<path
d="M60 10C30 10 15 35 15 55C15 75 30 95 45 100L45 110L55 110L55 100C55 100 60 102 65 100L65 110L75 110L75 100C90 95 105 75 105 55C105 35 90 10 60 10Z"
fill="url(#dream-lob-g)"
/>
<path d="M20 45C5 40 0 50 5 60C10 70 20 65 25 55C28 48 25 45 20 45Z" fill="url(#dream-lob-g)" />
<path
d="M100 45C115 40 120 50 115 60C110 70 100 65 95 55C92 48 95 45 100 45Z"
fill="url(#dream-lob-g)"
/>
<path d="M45 15Q38 8 35 14" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<path d="M75 15Q82 8 85 14" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<path
d="M39 36Q45 32 51 36"
stroke="#050810"
stroke-width="2.5"
stroke-linecap="round"
fill="none"
/>
<path
d="M69 36Q75 32 81 36"
stroke="#050810"
stroke-width="2.5"
stroke-linecap="round"
fill="none"
/>
</svg>
`;
export function renderDreams(props: DreamsProps) {
const idle = !props.active;
const dreamText = props.dreamingOf ?? currentDreamPhrase();
return html`
<section class="dreams ${idle ? "dreams--idle" : ""}">
${STARS.map(
(s) => html`
<div
class="dreams__star"
style="
top: ${s.top}%;
left: ${s.left}%;
width: ${s.size}px;
height: ${s.size}px;
background: ${s.hue === "accent" ? "var(--accent-muted)" : "var(--text)"};
animation-delay: ${s.delay}s;
"
></div>
`,
)}
<div class="dreams__moon"></div>
${props.active
? html`
<div class="dreams__bubble">
<span class="dreams__bubble-text">${dreamText}</span>
</div>
<div
class="dreams__bubble-dot"
style="top: calc(50% - 100px); left: calc(50% - 80px); width: 12px; height: 12px; animation-delay: 0.2s;"
></div>
<div
class="dreams__bubble-dot"
style="top: calc(50% - 70px); left: calc(50% - 50px); width: 8px; height: 8px; animation-delay: 0.4s;"
></div>
`
: nothing}
<div class="dreams__glow"></div>
<div class="dreams__lobster">${sleepingLobster}</div>
<span class="dreams__z">z</span>
<span class="dreams__z">z</span>
<span class="dreams__z">Z</span>
<div class="dreams__status">
<span class="dreams__status-label"
>${props.active ? "Sleep Maintenance Active" : "Sleep Idle"}</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.timezone ? html`· ${props.timezone}` : nothing}
</span>
</div>
</div>
<div class="dreams__stats">
<div class="dreams__stat">
<span class="dreams__stat-value" style="color: var(--text-strong);"
>${props.shortTermCount}</span
>
<span class="dreams__stat-label">Short-term</span>
</div>
<div class="dreams__stat-divider"></div>
<div class="dreams__stat">
<span class="dreams__stat-value" style="color: var(--accent);"
>${props.longTermCount}</span
>
<span class="dreams__stat-label">Long-term</span>
</div>
<div class="dreams__stat-divider"></div>
<div class="dreams__stat">
<span class="dreams__stat-value" style="color: var(--accent-2);"
>${props.promotedCount}</span
>
<span class="dreams__stat-label">Promoted Today</span>
</div>
</div>
<div class="dreams__controls">
<div class="dreams__controls-head">
<div>
<div class="dreams__controls-title">Sleep phases</div>
<div class="dreams__controls-subtitle">Light sorts, deep keeps, REM reflects.</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>
<button
class="btn btn--sm ${props.active ? "btn--subtle" : ""}"
?disabled=${props.modeSaving}
@click=${() => props.onToggleEnabled(!props.active)}
>
${props.active ? "Disable Sleep" : "Enable Sleep"}
</button>
</div>
</div>
<div class="dreams__phase-grid">
${props.phases.map(
(phase) => html`
<article class="dreams__phase ${phase.enabled ? "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>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || !props.active}
@click=${() => props.onTogglePhase(phase.id, !phase.enabled)}
>
${phase.enabled ? "Pause" : "Enable"}
</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>
</div>
</article>
`,
)}
</div>
${props.statusError
? html`<div class="dreams__controls-error">${props.statusError}</div>`
: nothing}
</div>
</section>
`;
}