diff --git a/extensions/memory-core/src/dreaming-markdown.ts b/extensions/memory-core/src/dreaming-markdown.ts new file mode 100644 index 00000000000..0e9ecfe44f9 --- /dev/null +++ b/extensions/memory-core/src/dreaming-markdown.ts @@ -0,0 +1,155 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + formatMemoryDreamingDay, + type MemoryDreamingPhaseName, + type MemoryDreamingStorageConfig, +} from "openclaw/plugin-sdk/memory-core-host-status"; + +const DAILY_PHASE_HEADINGS: Record, string> = { + light: "## Light Sleep", + rem: "## REM Sleep", +}; + +const DAILY_PHASE_LABELS: Record, string> = { + light: "light", + rem: "rem", +}; + +function resolvePhaseMarkers(phase: Exclude): { + start: string; + end: string; +} { + const label = DAILY_PHASE_LABELS[phase]; + return { + start: ``, + 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 = formatMemoryDreamingDay(epochMs, timezone); + return path.join(workspaceDir, "memory", `${isoDay}.md`); +} + +function resolveSeparateReportPath( + workspaceDir: string, + phase: MemoryDreamingPhaseName, + epochMs: number, + timezone?: string, +): string { + const isoDay = formatMemoryDreamingDay(epochMs, timezone); + return path.join(workspaceDir, "memory", "dreaming", phase, `${isoDay}.md`); +} + +function shouldWriteInline(storage: MemoryDreamingStorageConfig): boolean { + return storage.mode === "inline" || storage.mode === "both"; +} + +function shouldWriteSeparate(storage: MemoryDreamingStorageConfig): boolean { + return storage.mode === "separate" || storage.mode === "both" || storage.separateReports; +} + +export async function writeDailyDreamingPhaseBlock(params: { + workspaceDir: string; + phase: Exclude; + bodyLines: string[]; + nowMs?: number; + timezone?: string; + storage: MemoryDreamingStorageConfig; +}): 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 writeDeepDreamingReport(params: { + workspaceDir: string; + bodyLines: string[]; + nowMs?: number; + timezone?: string; + storage: MemoryDreamingStorageConfig; +}): Promise { + 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; +} diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts new file mode 100644 index 00000000000..a012b3d6235 --- /dev/null +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -0,0 +1,657 @@ +import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core"; +import { + resolveMemoryCorePluginConfig, + resolveMemoryLightDreamingConfig, + resolveMemoryRemDreamingConfig, + resolveMemoryDreamingWorkspaces, + type MemoryLightDreamingConfig, + type MemoryRemDreamingConfig, + type MemoryDreamingPhaseName, +} from "openclaw/plugin-sdk/memory-core-host-status"; +import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js"; +import { readShortTermRecallEntries, type ShortTermRecallEntry } from "./short-term-promotion.js"; + +type Logger = Pick; + +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; + add: (input: ManagedCronJobCreate) => Promise; + update: (id: string, patch: ManagedCronJobPatch) => Promise; + remove: (id: string) => Promise<{ removed?: boolean }>; +}; + +const LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming"; +const LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]"; +const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__"; + +const REM_SLEEP_CRON_NAME = "Memory REM Dreaming"; +const REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]"; +const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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} dreaming (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; + 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 { + 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 + ? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir) + : []; + const seen = new Set(); + 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 { + 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 buildLightDreamingBody(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 buildRemDreamingBody( + entries: ShortTermRecallEntry[], + limit: number, + minPatternStrength: number, +): string[] { + const tagStats = new Map }>(); + for (const entry of entries) { + for (const tag of entry.conceptTags) { + if (!tag) { + continue; + } + const stat = tagStats.get(tag) ?? { count: 0, evidence: new Set() }; + 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 runLightDreaming(params: { + workspaceDir: string; + config: MemoryLightDreamingConfig & { + timezone?: string; + storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; + }; + logger: Logger; + nowMs?: number; +}): Promise { + 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 = buildLightDreamingBody(entries.slice(0, params.config.limit)); + await writeDailyDreamingPhaseBlock({ + 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 dreaming staged ${Math.min(entries.length, params.config.limit)} candidate(s) [workspace=${params.workspaceDir}].`, + ); + } +} + +async function runRemDreaming(params: { + workspaceDir: string; + config: MemoryRemDreamingConfig & { + timezone?: string; + storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; + }; + logger: Logger; + nowMs?: number; +}): Promise { + 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 = buildRemDreamingBody( + entries, + params.config.limit, + params.config.minPatternStrength, + ); + await writeDailyDreamingPhaseBlock({ + 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 dreaming 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: + | (MemoryLightDreamingConfig & { + timezone?: string; + storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; + }) + | (MemoryRemDreamingConfig & { + 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} dreaming disabled` }; + } + const workspaces = resolveWorkspaces({ + cfg: params.cfg, + fallbackWorkspaceDir: params.workspaceDir, + }); + if (workspaces.length === 0) { + params.logger.warn( + `memory-core: ${params.phase} dreaming skipped because no memory workspace is available.`, + ); + return { handled: true, reason: `memory-core: ${params.phase} dreaming missing workspace` }; + } + if (params.config.limit === 0) { + params.logger.info(`memory-core: ${params.phase} dreaming skipped because limit=0.`); + return { handled: true, reason: `memory-core: ${params.phase} dreaming disabled by limit` }; + } + for (const workspaceDir of workspaces) { + try { + if (params.phase === "light") { + await runLightDreaming({ + workspaceDir, + config: params.config as MemoryLightDreamingConfig & { + timezone?: string; + storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; + }, + logger: params.logger, + }); + } else { + await runRemDreaming({ + workspaceDir, + config: params.config as MemoryRemDreamingConfig & { + timezone?: string; + storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; + }, + logger: params.logger, + }); + } + } catch (err) { + params.logger.error( + `memory-core: ${params.phase} dreaming failed for workspace ${workspaceDir}: ${formatErrorMessage(err)}`, + ); + } + } + return { handled: true, reason: `memory-core: ${params.phase} dreaming processed` }; +} + +export function registerMemoryDreamingPhases(api: OpenClawPluginApi): void { + api.registerHook( + "gateway:startup", + async (event: unknown) => { + const cron = resolveCronServiceFromStartupEvent(event); + const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig; + const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: api.config }); + const rem = resolveMemoryRemDreamingConfig({ 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: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`, + ); + } + }, + { name: "memory-core-dreaming-phase-cron" }, + ); + + api.on("before_agent_reply", async (event, ctx) => { + const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig; + const light = resolveMemoryLightDreamingConfig({ 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 = resolveMemoryRemDreamingConfig({ 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, + }); + }); +} diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts new file mode 100644 index 00000000000..fe447b90f45 --- /dev/null +++ b/src/memory-host-sdk/dreaming.test.ts @@ -0,0 +1,168 @@ +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 { + formatMemoryDreamingDay, + isSameMemoryDreamingDay, + resolveMemoryCorePluginConfig, + resolveMemoryDreamingConfig, + resolveMemoryDreamingWorkspaces, +} from "./dreaming.js"; + +describe("memory dreaming host helpers", () => { + it("normalizes string settings from the dreaming config", () => { + const resolved = resolveMemoryDreamingConfig({ + pluginConfig: { + dreaming: { + 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 = resolveMemoryDreamingConfig({ + 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(resolveMemoryDreamingWorkspaces(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(resolveMemoryDreamingWorkspaces(cfg)).toEqual([ + { + workspaceDir: "/workspace/fallback", + agentIds: ["fallback"], + }, + ]); + + expect( + formatMemoryDreamingDay(Date.parse("2026-04-02T06:30:00.000Z"), "America/Los_Angeles"), + ).toBe("2026-04-01"); + expect( + isSameMemoryDreamingDay( + 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: { + dreaming: { + enabled: true, + }, + }, + }, + }, + }, + } as OpenClawConfig), + ).toEqual({ + dreaming: { + enabled: true, + }, + }); + }); +}); diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts new file mode 100644 index 00000000000..263bf8ec175 --- /dev/null +++ b/src/memory-host-sdk/dreaming.ts @@ -0,0 +1,607 @@ +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_DREAMING_ENABLED = true; +export const DEFAULT_MEMORY_DREAMING_TIMEZONE = undefined; +export const DEFAULT_MEMORY_DREAMING_VERBOSE_LOGGING = false; +export const DEFAULT_MEMORY_DREAMING_STORAGE_MODE = "inline"; +export const DEFAULT_MEMORY_DREAMING_SEPARATE_REPORTS = false; + +export const DEFAULT_MEMORY_LIGHT_DREAMING_CRON_EXPR = "0 */6 * * *"; +export const DEFAULT_MEMORY_LIGHT_DREAMING_LOOKBACK_DAYS = 2; +export const DEFAULT_MEMORY_LIGHT_DREAMING_LIMIT = 100; +export const DEFAULT_MEMORY_LIGHT_DREAMING_DEDUPE_SIMILARITY = 0.9; + +export const DEFAULT_MEMORY_DEEP_DREAMING_CRON_EXPR = "0 3 * * *"; +export const DEFAULT_MEMORY_DEEP_DREAMING_LIMIT = 10; +export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE = 0.8; +export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT = 3; +export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES = 3; +export const DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS = 14; +export const DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS = 30; + +export const DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_ENABLED = true; +export const DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_TRIGGER_BELOW_HEALTH = 0.35; +export const DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_LOOKBACK_DAYS = 30; +export const DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_MAX_CANDIDATES = 20; +export const DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_MIN_CONFIDENCE = 0.9; +export const DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_AUTO_WRITE_MIN_CONFIDENCE = 0.97; + +export const DEFAULT_MEMORY_REM_DREAMING_CRON_EXPR = "0 5 * * 0"; +export const DEFAULT_MEMORY_REM_DREAMING_LOOKBACK_DAYS = 7; +export const DEFAULT_MEMORY_REM_DREAMING_LIMIT = 10; +export const DEFAULT_MEMORY_REM_DREAMING_MIN_PATTERN_STRENGTH = 0.75; + +export const DEFAULT_MEMORY_DREAMING_SPEED = "balanced"; +export const DEFAULT_MEMORY_DREAMING_THINKING = "medium"; +export const DEFAULT_MEMORY_DREAMING_BUDGET = "medium"; + +export type MemoryDreamingSpeed = "fast" | "balanced" | "slow"; +export type MemoryDreamingThinking = "low" | "medium" | "high"; +export type MemoryDreamingBudget = "cheap" | "medium" | "expensive"; +export type MemoryDreamingStorageMode = "inline" | "separate" | "both"; + +export type MemoryLightDreamingSource = "daily" | "sessions" | "recall"; +export type MemoryDeepDreamingSource = "daily" | "memory" | "sessions" | "logs" | "recall"; +export type MemoryRemDreamingSource = "memory" | "daily" | "deep"; + +export type MemoryDreamingExecutionConfig = { + speed: MemoryDreamingSpeed; + thinking: MemoryDreamingThinking; + budget: MemoryDreamingBudget; + model?: string; + maxOutputTokens?: number; + temperature?: number; + timeoutMs?: number; +}; + +export type MemoryDreamingStorageConfig = { + mode: MemoryDreamingStorageMode; + separateReports: boolean; +}; + +export type MemoryLightDreamingConfig = { + enabled: boolean; + cron: string; + lookbackDays: number; + limit: number; + dedupeSimilarity: number; + sources: MemoryLightDreamingSource[]; + execution: MemoryDreamingExecutionConfig; +}; + +export type MemoryDeepDreamingRecoveryConfig = { + enabled: boolean; + triggerBelowHealth: number; + lookbackDays: number; + maxRecoveredCandidates: number; + minRecoveryConfidence: number; + autoWriteMinConfidence: number; +}; + +export type MemoryDeepDreamingConfig = { + enabled: boolean; + cron: string; + limit: number; + minScore: number; + minRecallCount: number; + minUniqueQueries: number; + recencyHalfLifeDays: number; + maxAgeDays?: number; + sources: MemoryDeepDreamingSource[]; + recovery: MemoryDeepDreamingRecoveryConfig; + execution: MemoryDreamingExecutionConfig; +}; + +export type MemoryRemDreamingConfig = { + enabled: boolean; + cron: string; + lookbackDays: number; + limit: number; + minPatternStrength: number; + sources: MemoryRemDreamingSource[]; + execution: MemoryDreamingExecutionConfig; +}; + +export type MemoryDreamingPhaseName = "light" | "deep" | "rem"; + +export type MemoryDreamingConfig = { + enabled: boolean; + timezone?: string; + verboseLogging: boolean; + storage: MemoryDreamingStorageConfig; + execution: { + defaults: MemoryDreamingExecutionConfig; + }; + phases: { + light: MemoryLightDreamingConfig; + deep: MemoryDeepDreamingConfig; + rem: MemoryRemDreamingConfig; + }; +}; + +export type MemoryDreamingWorkspace = { + workspaceDir: string; + agentIds: string[]; +}; + +const DEFAULT_MEMORY_LIGHT_DREAMING_SOURCES: MemoryLightDreamingSource[] = [ + "daily", + "sessions", + "recall", +]; +const DEFAULT_MEMORY_DEEP_DREAMING_SOURCES: MemoryDeepDreamingSource[] = [ + "daily", + "memory", + "sessions", + "logs", + "recall", +]; +const DEFAULT_MEMORY_REM_DREAMING_SOURCES: MemoryRemDreamingSource[] = ["memory", "daily", "deep"]; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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( + 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): MemoryDreamingStorageMode { + const normalized = normalizeTrimmedString(value)?.toLowerCase(); + if (normalized === "inline" || normalized === "separate" || normalized === "both") { + return normalized; + } + return DEFAULT_MEMORY_DREAMING_STORAGE_MODE; +} + +function normalizeSpeed(value: unknown): MemoryDreamingSpeed | undefined { + const normalized = normalizeTrimmedString(value)?.toLowerCase(); + if (normalized === "fast" || normalized === "balanced" || normalized === "slow") { + return normalized; + } + return undefined; +} + +function normalizeThinking(value: unknown): MemoryDreamingThinking | undefined { + const normalized = normalizeTrimmedString(value)?.toLowerCase(); + if (normalized === "low" || normalized === "medium" || normalized === "high") { + return normalized; + } + return undefined; +} + +function normalizeBudget(value: unknown): MemoryDreamingBudget | undefined { + const normalized = normalizeTrimmedString(value)?.toLowerCase(); + if (normalized === "cheap" || normalized === "medium" || normalized === "expensive") { + return normalized; + } + return undefined; +} + +function resolveExecutionConfig( + value: unknown, + fallback: MemoryDreamingExecutionConfig, +): MemoryDreamingExecutionConfig { + 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 | undefined, +): Record | 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 resolveMemoryDreamingConfig(params: { + pluginConfig?: Record; + cfg?: OpenClawConfig; +}): MemoryDreamingConfig { + const dreaming = asRecord(params.pluginConfig?.dreaming); + const timezone = + normalizeTrimmedString(dreaming?.timezone) ?? + normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone) ?? + DEFAULT_MEMORY_DREAMING_TIMEZONE; + const storage = asRecord(dreaming?.storage); + const execution = asRecord(dreaming?.execution); + const phases = asRecord(dreaming?.phases); + + const defaultExecution = resolveExecutionConfig(execution?.defaults, { + speed: DEFAULT_MEMORY_DREAMING_SPEED, + thinking: DEFAULT_MEMORY_DREAMING_THINKING, + budget: DEFAULT_MEMORY_DREAMING_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(dreaming?.enabled, DEFAULT_MEMORY_DREAMING_ENABLED), + ...(timezone ? { timezone } : {}), + verboseLogging: normalizeBoolean( + dreaming?.verboseLogging, + DEFAULT_MEMORY_DREAMING_VERBOSE_LOGGING, + ), + storage: { + mode: normalizeStorageMode(storage?.mode), + separateReports: normalizeBoolean( + storage?.separateReports, + DEFAULT_MEMORY_DREAMING_SEPARATE_REPORTS, + ), + }, + execution: { + defaults: defaultExecution, + }, + phases: { + light: { + enabled: normalizeBoolean(light?.enabled, true), + cron: normalizeTrimmedString(light?.cron) ?? DEFAULT_MEMORY_LIGHT_DREAMING_CRON_EXPR, + lookbackDays: normalizeNonNegativeInt( + light?.lookbackDays, + DEFAULT_MEMORY_LIGHT_DREAMING_LOOKBACK_DAYS, + ), + limit: normalizeNonNegativeInt(light?.limit, DEFAULT_MEMORY_LIGHT_DREAMING_LIMIT), + dedupeSimilarity: normalizeSimilarity( + light?.dedupeSimilarity, + DEFAULT_MEMORY_LIGHT_DREAMING_DEDUPE_SIMILARITY, + ), + sources: normalizeStringArray( + light?.sources, + ["daily", "sessions", "recall"] as const, + DEFAULT_MEMORY_LIGHT_DREAMING_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_DREAMING_CRON_EXPR, + limit: normalizeNonNegativeInt(deep?.limit, DEFAULT_MEMORY_DEEP_DREAMING_LIMIT), + minScore: normalizeScore(deep?.minScore, DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE), + minRecallCount: normalizeNonNegativeInt( + deep?.minRecallCount, + DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT, + ), + minUniqueQueries: normalizeNonNegativeInt( + deep?.minUniqueQueries, + DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES, + ), + recencyHalfLifeDays: normalizeNonNegativeInt( + deep?.recencyHalfLifeDays, + DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS, + ), + ...(typeof maxAgeDays === "number" + ? { maxAgeDays } + : typeof DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS === "number" + ? { maxAgeDays: DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS } + : {}), + sources: normalizeStringArray( + deep?.sources, + ["daily", "memory", "sessions", "logs", "recall"] as const, + DEFAULT_MEMORY_DEEP_DREAMING_SOURCES, + ), + recovery: { + enabled: normalizeBoolean( + deepRecovery?.enabled, + DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_ENABLED, + ), + triggerBelowHealth: normalizeScore( + deepRecovery?.triggerBelowHealth, + DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_TRIGGER_BELOW_HEALTH, + ), + lookbackDays: normalizeNonNegativeInt( + deepRecovery?.lookbackDays, + DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_LOOKBACK_DAYS, + ), + maxRecoveredCandidates: normalizeNonNegativeInt( + deepRecovery?.maxRecoveredCandidates, + DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_MAX_CANDIDATES, + ), + minRecoveryConfidence: normalizeScore( + deepRecovery?.minRecoveryConfidence, + DEFAULT_MEMORY_DEEP_DREAMING_RECOVERY_MIN_CONFIDENCE, + ), + autoWriteMinConfidence: normalizeScore( + deepRecovery?.autoWriteMinConfidence, + DEFAULT_MEMORY_DEEP_DREAMING_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_DREAMING_CRON_EXPR, + lookbackDays: normalizeNonNegativeInt( + rem?.lookbackDays, + DEFAULT_MEMORY_REM_DREAMING_LOOKBACK_DAYS, + ), + limit: normalizeNonNegativeInt(rem?.limit, DEFAULT_MEMORY_REM_DREAMING_LIMIT), + minPatternStrength: normalizeScore( + rem?.minPatternStrength, + DEFAULT_MEMORY_REM_DREAMING_MIN_PATTERN_STRENGTH, + ), + sources: normalizeStringArray( + rem?.sources, + ["memory", "daily", "deep"] as const, + DEFAULT_MEMORY_REM_DREAMING_SOURCES, + ), + execution: resolveExecutionConfig(rem?.execution, { + ...defaultExecution, + speed: "slow", + thinking: "high", + budget: "expensive", + }), + }, + }, + }; +} + +export function resolveMemoryDeepDreamingConfig(params: { + pluginConfig?: Record; + cfg?: OpenClawConfig; +}): MemoryDeepDreamingConfig & { + timezone?: string; + verboseLogging: boolean; + storage: MemoryDreamingStorageConfig; +} { + const resolved = resolveMemoryDreamingConfig(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 resolveMemoryLightDreamingConfig(params: { + pluginConfig?: Record; + cfg?: OpenClawConfig; +}): MemoryLightDreamingConfig & { + timezone?: string; + verboseLogging: boolean; + storage: MemoryDreamingStorageConfig; +} { + const resolved = resolveMemoryDreamingConfig(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 resolveMemoryRemDreamingConfig(params: { + pluginConfig?: Record; + cfg?: OpenClawConfig; +}): MemoryRemDreamingConfig & { + timezone?: string; + verboseLogging: boolean; + storage: MemoryDreamingStorageConfig; +} { + const resolved = resolveMemoryDreamingConfig(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 formatMemoryDreamingDay(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 isSameMemoryDreamingDay( + firstEpochMs: number, + secondEpochMs: number, + timezone?: string, +): boolean { + return ( + formatMemoryDreamingDay(firstEpochMs, timezone) === + formatMemoryDreamingDay(secondEpochMs, timezone) + ); +} + +export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDreamingWorkspace[] { + const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const agentIds: string[] = []; + const seenAgents = new Set(); + 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(); + 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()]; +} diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts new file mode 100644 index 00000000000..da067c5ea8c --- /dev/null +++ b/ui/src/ui/views/dreaming.test.ts @@ -0,0 +1,151 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderDreaming, type DreamingProps } from "./dreaming.ts"; + +function buildProps(overrides?: Partial): DreamingProps { + 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: DreamingProps): HTMLDivElement { + const container = document.createElement("div"); + render(renderDreaming(props), container); + return container; +} + +describe("dreaming 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("Dreaming Active"); + }); + + it("shows idle status label when inactive", () => { + const container = renderInto(buildProps({ active: false })); + const label = container.querySelector(".dreams__status-label"); + expect(label?.textContent).toBe("Dreaming Idle"); + }); + + 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(".dreams__phase .btn")?.click(); + + expect(onTogglePhase).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/ui/views/dreaming.ts b/ui/src/ui/views/dreaming.ts new file mode 100644 index 00000000000..3b39b161fec --- /dev/null +++ b/ui/src/ui/views/dreaming.ts @@ -0,0 +1,258 @@ +import { html, nothing } from "lit"; +import type { DreamingPhaseId } from "../controllers/dreaming.ts"; + +export type DreamingProps = { + active: boolean; + shortTermCount: number; + longTermCount: number; + promotedCount: number; + dreamingOf: string | null; + nextCycle: string | null; + timezone: string | null; + phases: Array<{ + id: DreamingPhaseId; + label: string; + detail: string; + enabled: boolean; + nextCycle: string | null; + managedCronPresent: boolean; + }>; + statusLoading: boolean; + statusError: string | null; + modeSaving: boolean; + onRefresh: () => void; + onToggleEnabled: (enabled: boolean) => void; + onTogglePhase: (phase: DreamingPhaseId, enabled: boolean) => void; +}; + +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` + + + + + + + + + + + + + + + +`; + +export function renderDreaming(props: DreamingProps) { + const idle = !props.active; + const dreamText = props.dreamingOf ?? currentDreamPhrase(); + + return html` +
+ ${STARS.map( + (s) => html` +
+ `, + )} + +
+ + ${props.active + ? html` +
+ ${dreamText} +
+
+
+ ` + : nothing} + +
+
${sleepingLobster}
+ z + z + Z + +
+ ${props.active ? "Dreaming Active" : "Dreaming Idle"} +
+
+ + ${props.promotedCount} promoted + ${props.nextCycle ? html`· next phase ${props.nextCycle}` : nothing} + ${props.timezone ? html`· ${props.timezone}` : nothing} + +
+
+ +
+
+ ${props.shortTermCount} + Short-term +
+
+
+ ${props.longTermCount} + Long-term +
+
+
+ ${props.promotedCount} + Promoted Today +
+
+ +
+
+
+
Dreaming Phases
+
+ Light sleep sorts, deep sleep keeps, REM reflects. +
+
+
+ + +
+
+
+ ${props.phases.map( + (phase) => html` +
+
+
+
${phase.label}
+
${phase.detail}
+
+ +
+
+ ${phase.enabled ? "scheduled" : "off"} + ${phase.nextCycle ? `next ${phase.nextCycle}` : "no next run"} + ${phase.managedCronPresent ? "managed cron" : "cron missing"} +
+
+ `, + )} +
+ ${props.statusError + ? html`
${props.statusError}
` + : nothing} +
+
+ `; +}