mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix(memory-core): add dreaming rename artifacts
This commit is contained in:
155
extensions/memory-core/src/dreaming-markdown.ts
Normal file
155
extensions/memory-core/src/dreaming-markdown.ts
Normal file
@@ -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<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
|
||||
light: "## Light Sleep",
|
||||
rem: "## REM Sleep",
|
||||
};
|
||||
|
||||
const DAILY_PHASE_LABELS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
|
||||
light: "light",
|
||||
rem: "rem",
|
||||
};
|
||||
|
||||
function resolvePhaseMarkers(phase: Exclude<MemoryDreamingPhaseName, "deep">): {
|
||||
start: string;
|
||||
end: string;
|
||||
} {
|
||||
const label = DAILY_PHASE_LABELS[phase];
|
||||
return {
|
||||
start: `<!-- openclaw:dreaming:${label}:start -->`,
|
||||
end: `<!-- openclaw:dreaming:${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 = 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<MemoryDreamingPhaseName, "deep">;
|
||||
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<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;
|
||||
}
|
||||
657
extensions/memory-core/src/dreaming-phases.ts
Normal file
657
extensions/memory-core/src/dreaming-phases.ts
Normal file
@@ -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<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 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<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} 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<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
|
||||
? resolveMemoryDreamingWorkspaces(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 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<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 runLightDreaming(params: {
|
||||
workspaceDir: string;
|
||||
config: MemoryLightDreamingConfig & {
|
||||
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 = 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<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 = 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
168
src/memory-host-sdk/dreaming.test.ts
Normal file
168
src/memory-host-sdk/dreaming.test.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
607
src/memory-host-sdk/dreaming.ts
Normal file
607
src/memory-host-sdk/dreaming.ts
Normal file
@@ -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<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): 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<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 resolveMemoryDreamingConfig(params: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<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, MemoryDreamingWorkspace>();
|
||||
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()];
|
||||
}
|
||||
151
ui/src/ui/views/dreaming.test.ts
Normal file
151
ui/src/ui/views/dreaming.test.ts
Normal file
@@ -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>): 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<HTMLButtonElement>(".dreams__phase .btn")?.click();
|
||||
|
||||
expect(onTogglePhase).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
258
ui/src/ui/views/dreaming.ts
Normal file
258
ui/src/ui/views/dreaming.ts
Normal file
@@ -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`
|
||||
<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 renderDreaming(props: DreamingProps) {
|
||||
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 ? "Dreaming Active" : "Dreaming 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">Dreaming Phases</div>
|
||||
<div class="dreams__controls-subtitle">
|
||||
Light sleep sorts, deep sleep 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 Dreaming" : "Enable Dreaming"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dreams__phase-grid">
|
||||
${props.phases.map(
|
||||
(phase) => html`
|
||||
<article class="dreams__phase ${phase.enabled ? "dreams__phase--active" : ""}">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user