mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
Dreaming: move setup controls to header and tighten status plumbing
This commit is contained in:
@@ -165,20 +165,16 @@ function loadBundledChannelContractSurfaceEntries(): Array<{
|
||||
if (manifest.origin !== "bundled" || manifest.channels.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const modulePaths = resolveSourceFirstContractSurfaceModulePaths({
|
||||
const modulePath = resolveSourceFirstContractSurfaceModulePaths({
|
||||
rootDir: manifest.rootDir,
|
||||
});
|
||||
if (modulePaths.length === 0) {
|
||||
})[0];
|
||||
if (!modulePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const surface = Object.assign(
|
||||
{},
|
||||
...modulePaths.map((modulePath) => loadModule(modulePath)(modulePath) as object),
|
||||
);
|
||||
surfaces.push({
|
||||
pluginId: manifest.id,
|
||||
surface,
|
||||
surface: loadModule(modulePath)(modulePath),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
@@ -19,12 +22,24 @@ vi.mock("../../plugins/memory-runtime.js", () => ({
|
||||
|
||||
import { doctorHandlers } from "./doctor.js";
|
||||
|
||||
const invokeDoctorMemoryStatus = async (respond: ReturnType<typeof vi.fn>) => {
|
||||
const invokeDoctorMemoryStatus = async (
|
||||
respond: ReturnType<typeof vi.fn>,
|
||||
context?: { cron?: { list?: ReturnType<typeof vi.fn> } },
|
||||
) => {
|
||||
const cronList =
|
||||
context?.cron?.list ??
|
||||
vi.fn(async () => {
|
||||
return [];
|
||||
});
|
||||
await doctorHandlers["doctor.memory.status"]({
|
||||
req: {} as never,
|
||||
params: {} as never,
|
||||
respond: respond as never,
|
||||
context: {} as never,
|
||||
context: {
|
||||
cron: {
|
||||
list: cronList,
|
||||
},
|
||||
} as never,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
@@ -33,13 +48,13 @@ const invokeDoctorMemoryStatus = async (respond: ReturnType<typeof vi.fn>) => {
|
||||
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
embedding: {
|
||||
ok: false,
|
||||
error,
|
||||
},
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
@@ -71,11 +86,19 @@ describe("doctor.memory.status", () => {
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
provider: "gemini",
|
||||
embedding: { ok: true },
|
||||
},
|
||||
dreaming: expect.objectContaining({
|
||||
mode: "off",
|
||||
enabled: false,
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
managedCronPresent: false,
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(close).toHaveBeenCalled();
|
||||
@@ -109,4 +132,110 @@ describe("doctor.memory.status", () => {
|
||||
expectEmbeddingErrorResponse(respond, "gateway memory probe failed: timeout");
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes dreaming counts and managed cron status when workspace data is available", async () => {
|
||||
const now = Date.now();
|
||||
const todayIso = new Date(now).toISOString();
|
||||
const earlierIso = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
|
||||
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: todayIso,
|
||||
entries: {
|
||||
"memory:memory/2026-04-03.md:1:2": {
|
||||
path: "memory/2026-04-03.md",
|
||||
source: "memory",
|
||||
promotedAt: undefined,
|
||||
},
|
||||
"memory:memory/2026-04-02.md:1:2": {
|
||||
path: "memory/2026-04-02.md",
|
||||
source: "memory",
|
||||
promotedAt: todayIso,
|
||||
},
|
||||
"memory:memory/2026-04-01.md:1:2": {
|
||||
path: "memory/2026-04-01.md",
|
||||
source: "memory",
|
||||
promotedAt: earlierIso,
|
||||
},
|
||||
"memory:MEMORY.md:1:2": {
|
||||
path: "MEMORY.md",
|
||||
source: "memory",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "rem",
|
||||
frequency: "0 */4 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const cronList = vi.fn(async () => [
|
||||
{
|
||||
name: "Memory Dreaming Promotion",
|
||||
description: "[managed-by=memory-core.short-term-promotion] test",
|
||||
enabled: true,
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "__openclaw_memory_core_short_term_promotion_dream__",
|
||||
},
|
||||
state: { nextRunAtMs: now + 60_000 },
|
||||
},
|
||||
]);
|
||||
const respond = vi.fn();
|
||||
|
||||
try {
|
||||
await invokeDoctorMemoryStatus(respond, { cron: { list: cronList } });
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
provider: "gemini",
|
||||
embedding: { ok: true },
|
||||
dreaming: expect.objectContaining({
|
||||
mode: "rem",
|
||||
enabled: true,
|
||||
frequency: "0 */4 * * *",
|
||||
shortTermCount: 1,
|
||||
promotedTotal: 2,
|
||||
promotedToday: 1,
|
||||
managedCronPresent: true,
|
||||
nextRunAtMs: now + 60_000,
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(close).toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,73 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
|
||||
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
|
||||
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
|
||||
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
|
||||
|
||||
type DreamingMode = "off" | "core" | "rem" | "deep";
|
||||
type DreamingPreset = Exclude<DreamingMode, "off">;
|
||||
|
||||
const DREAMING_PRESET_DEFAULTS: Record<
|
||||
DreamingPreset,
|
||||
{
|
||||
frequency: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
}
|
||||
> = {
|
||||
core: {
|
||||
frequency: "0 3 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.75,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 2,
|
||||
},
|
||||
deep: {
|
||||
frequency: "0 */12 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.8,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
rem: {
|
||||
frequency: "0 */6 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
type DoctorMemoryDreamingPayload = {
|
||||
mode: DreamingMode;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
timezone?: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
shortTermCount: number;
|
||||
promotedTotal: number;
|
||||
promotedToday: number;
|
||||
storePath?: string;
|
||||
lastPromotedAt?: string;
|
||||
nextRunAtMs?: number;
|
||||
managedCronPresent: boolean;
|
||||
storeError?: string;
|
||||
};
|
||||
|
||||
export type DoctorMemoryStatusPayload = {
|
||||
agentId: string;
|
||||
provider?: string;
|
||||
@@ -11,10 +75,256 @@ export type DoctorMemoryStatusPayload = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
dreaming?: DoctorMemoryDreamingPayload;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeDreamingMode(value: unknown): DreamingMode {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "rem" ||
|
||||
normalized === "deep"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const floored = Math.floor(value);
|
||||
return floored < 0 ? fallback : floored;
|
||||
}
|
||||
|
||||
function normalizeScore(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
if (value < 0 || value > 1) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveDreamingConfig(
|
||||
cfg: Record<string, unknown>,
|
||||
): Omit<
|
||||
DoctorMemoryDreamingPayload,
|
||||
| "shortTermCount"
|
||||
| "promotedTotal"
|
||||
| "promotedToday"
|
||||
| "storePath"
|
||||
| "lastPromotedAt"
|
||||
| "nextRunAtMs"
|
||||
| "managedCronPresent"
|
||||
| "storeError"
|
||||
> {
|
||||
const plugins = asRecord(cfg.plugins);
|
||||
const entries = asRecord(plugins?.entries);
|
||||
const memoryCore = asRecord(entries?.["memory-core"]);
|
||||
const pluginConfig = asRecord(memoryCore?.config);
|
||||
const dreaming = asRecord(pluginConfig?.dreaming);
|
||||
const mode = normalizeDreamingMode(dreaming?.mode);
|
||||
const preset: DreamingPreset = mode === "off" ? "core" : mode;
|
||||
const defaults = DREAMING_PRESET_DEFAULTS[preset];
|
||||
|
||||
return {
|
||||
mode,
|
||||
enabled: mode !== "off",
|
||||
frequency: normalizeTrimmedString(dreaming?.frequency) ?? defaults.frequency,
|
||||
timezone: normalizeTrimmedString(dreaming?.timezone),
|
||||
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
|
||||
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
|
||||
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
|
||||
minUniqueQueries: normalizeNonNegativeInt(
|
||||
dreaming?.minUniqueQueries,
|
||||
defaults.minUniqueQueries,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemoryPath(rawPath: string): string {
|
||||
return rawPath.replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function isShortTermMemoryPath(filePath: string): boolean {
|
||||
const normalized = normalizeMemoryPath(filePath);
|
||||
if (SHORT_TERM_PATH_RE.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return SHORT_TERM_BASENAME_RE.test(normalized);
|
||||
}
|
||||
|
||||
function isSameLocalDay(firstEpochMs: number, secondEpochMs: number): boolean {
|
||||
const first = new Date(firstEpochMs);
|
||||
const second = new Date(secondEpochMs);
|
||||
return (
|
||||
first.getFullYear() === second.getFullYear() &&
|
||||
first.getMonth() === second.getMonth() &&
|
||||
first.getDate() === second.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
type DreamingStoreStats = Pick<
|
||||
DoctorMemoryDreamingPayload,
|
||||
| "shortTermCount"
|
||||
| "promotedTotal"
|
||||
| "promotedToday"
|
||||
| "storePath"
|
||||
| "lastPromotedAt"
|
||||
| "storeError"
|
||||
>;
|
||||
|
||||
async function loadDreamingStoreStats(
|
||||
workspaceDir: string,
|
||||
nowMs: number,
|
||||
): Promise<DreamingStoreStats> {
|
||||
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
|
||||
try {
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const store = asRecord(parsed);
|
||||
const entries = asRecord(store?.entries) ?? {};
|
||||
let shortTermCount = 0;
|
||||
let promotedTotal = 0;
|
||||
let promotedToday = 0;
|
||||
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
|
||||
let latestPromotedAt: string | undefined;
|
||||
|
||||
for (const value of Object.values(entries)) {
|
||||
const entry = asRecord(value);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const source = normalizeTrimmedString(entry.source);
|
||||
const entryPath = normalizeTrimmedString(entry.path);
|
||||
if (source !== "memory" || !entryPath || !isShortTermMemoryPath(entryPath)) {
|
||||
continue;
|
||||
}
|
||||
const promotedAt = normalizeTrimmedString(entry.promotedAt);
|
||||
if (!promotedAt) {
|
||||
shortTermCount += 1;
|
||||
continue;
|
||||
}
|
||||
promotedTotal += 1;
|
||||
const promotedAtMs = Date.parse(promotedAt);
|
||||
if (Number.isFinite(promotedAtMs) && isSameLocalDay(promotedAtMs, nowMs)) {
|
||||
promotedToday += 1;
|
||||
}
|
||||
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
|
||||
latestPromotedAtMs = promotedAtMs;
|
||||
latestPromotedAt = promotedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shortTermCount,
|
||||
promotedTotal,
|
||||
promotedToday,
|
||||
storePath,
|
||||
...(latestPromotedAt ? { lastPromotedAt: latestPromotedAt } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
storePath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
storePath,
|
||||
storeError: formatError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedDreamingCronStatus = {
|
||||
managedCronPresent: boolean;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
type ManagedCronJobLike = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
payload?: { kind?: string; text?: string };
|
||||
state?: { nextRunAtMs?: number };
|
||||
};
|
||||
|
||||
function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
|
||||
const description = normalizeTrimmedString(job.description);
|
||||
if (description?.includes(MANAGED_DREAMING_CRON_TAG)) {
|
||||
return true;
|
||||
}
|
||||
const name = normalizeTrimmedString(job.name);
|
||||
const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase();
|
||||
const payloadText = normalizeTrimmedString(job.payload?.text);
|
||||
return (
|
||||
name === MANAGED_DREAMING_CRON_NAME &&
|
||||
payloadKind === "systemevent" &&
|
||||
payloadText === DREAMING_SYSTEM_EVENT_TEXT
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveManagedDreamingCronStatus(context: {
|
||||
cron?: { list?: (opts?: { includeDisabled?: boolean }) => Promise<unknown[]> };
|
||||
}): Promise<ManagedDreamingCronStatus> {
|
||||
if (!context.cron || typeof context.cron.list !== "function") {
|
||||
return { managedCronPresent: false };
|
||||
}
|
||||
try {
|
||||
const jobs = await context.cron.list({ includeDisabled: true });
|
||||
const managed = jobs
|
||||
.filter((job): job is ManagedCronJobLike => typeof job === "object" && job !== null)
|
||||
.filter(isManagedDreamingJob);
|
||||
let nextRunAtMs: number | undefined;
|
||||
for (const job of managed) {
|
||||
if (job.enabled !== true) {
|
||||
continue;
|
||||
}
|
||||
const candidate = job.state?.nextRunAtMs;
|
||||
if (typeof candidate !== "number" || !Number.isFinite(candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (nextRunAtMs === undefined || candidate < nextRunAtMs) {
|
||||
nextRunAtMs = candidate;
|
||||
}
|
||||
}
|
||||
return {
|
||||
managedCronPresent: managed.length > 0,
|
||||
...(nextRunAtMs !== undefined ? { nextRunAtMs } : {}),
|
||||
};
|
||||
} catch {
|
||||
return { managedCronPresent: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const doctorHandlers: GatewayRequestHandlers = {
|
||||
"doctor.memory.status": async ({ respond }) => {
|
||||
"doctor.memory.status": async ({ respond, context }) => {
|
||||
const cfg = loadConfig();
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const { manager, error } = await getActiveMemorySearchManager({
|
||||
@@ -40,10 +350,26 @@ export const doctorHandlers: GatewayRequestHandlers = {
|
||||
if (!embedding.ok && !embedding.error) {
|
||||
embedding = { ok: false, error: "memory embeddings unavailable" };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const dreamingConfig = resolveDreamingConfig(cfg as Record<string, unknown>);
|
||||
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
|
||||
const storeStats = workspaceDir
|
||||
? await loadDreamingStoreStats(workspaceDir, nowMs)
|
||||
: {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
};
|
||||
const cronStatus = await resolveManagedDreamingCronStatus(context);
|
||||
const payload: DoctorMemoryStatusPayload = {
|
||||
agentId,
|
||||
provider: status.provider,
|
||||
embedding,
|
||||
dreaming: {
|
||||
...dreamingConfig,
|
||||
...storeStats,
|
||||
...cronStatus,
|
||||
},
|
||||
};
|
||||
respond(true, payload, undefined);
|
||||
} catch (err) {
|
||||
|
||||
@@ -238,10 +238,11 @@
|
||||
|
||||
.dreams__stats {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
bottom: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dreams__stat {
|
||||
@@ -270,6 +271,118 @@
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.dreams__controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dreams__controls-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dreams__controls-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dreams__mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dreams__mode {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, var(--panel) 90%, transparent);
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.dreams__mode:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dreams__mode:hover:not(:disabled) {
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dreams__mode--active {
|
||||
border-color: color-mix(in oklab, var(--accent) 62%, var(--border));
|
||||
background: color-mix(in oklab, var(--accent-subtle) 70%, var(--panel));
|
||||
}
|
||||
|
||||
.dreams__mode-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dreams__mode-detail {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dreams__controls-note,
|
||||
.dreams__controls-error {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dreams__controls-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.dreams {
|
||||
min-height: calc(100vh - 96px);
|
||||
}
|
||||
|
||||
.dreams__stats {
|
||||
position: static;
|
||||
margin-top: 36px;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.dreams__controls {
|
||||
position: static;
|
||||
margin: 12px 16px 18px;
|
||||
width: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--panel) 84%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dreams__mode-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Status line ---- */
|
||||
|
||||
.dreams__status {
|
||||
|
||||
@@ -924,6 +924,58 @@
|
||||
.page-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dreaming-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dreaming-header-controls__modes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
|
||||
}
|
||||
|
||||
.dreaming-header-controls__mode {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.dreaming-header-controls__mode:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dreaming-header-controls__mode:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dreaming-header-controls__mode--active {
|
||||
border-color: color-mix(in srgb, var(--accent) 62%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent-subtle) 70%, var(--bg-elevated));
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Chat view header adjustments */
|
||||
|
||||
@@ -69,6 +69,11 @@ import {
|
||||
revokeDeviceToken,
|
||||
rotateDeviceToken,
|
||||
} from "./controllers/devices.ts";
|
||||
import {
|
||||
loadDreamingStatus,
|
||||
updateDreamingMode,
|
||||
type DreamingMode,
|
||||
} from "./controllers/dreaming.ts";
|
||||
import {
|
||||
loadExecApprovals,
|
||||
removeExecApprovalsFormValue,
|
||||
@@ -144,18 +149,41 @@ const lazyNodes = createLazy(() => import("./views/nodes.ts"));
|
||||
const lazySessions = createLazy(() => import("./views/sessions.ts"));
|
||||
const lazySkills = createLazy(() => import("./views/skills.ts"));
|
||||
const lazyDreams = createLazy(() => import("./views/dreams.ts"));
|
||||
const DREAMING_MODE_OPTIONS: Array<{ id: DreamingMode; label: string; detail: string }> = [
|
||||
{ id: "off", label: "Off", detail: "No automatic promotions" },
|
||||
{ id: "core", label: "Core", detail: "Nightly cadence" },
|
||||
{ id: "rem", label: "REM", detail: "Every 6 hours" },
|
||||
{ id: "deep", label: "Deep", detail: "Every 12 hours, stricter" },
|
||||
];
|
||||
|
||||
function isDreamingEnabled(configValue: Record<string, unknown> | null): boolean {
|
||||
function resolveDreamingMode(configValue: Record<string, unknown> | null): DreamingMode {
|
||||
if (!configValue) {
|
||||
return false;
|
||||
return "off";
|
||||
}
|
||||
const plugins = configValue.plugins as Record<string, unknown> | undefined;
|
||||
const entries = plugins?.entries as Record<string, unknown> | undefined;
|
||||
const memoryCore = entries?.["memory-core"] as Record<string, unknown> | undefined;
|
||||
const config = memoryCore?.config as Record<string, unknown> | undefined;
|
||||
const dreaming = config?.dreaming as Record<string, unknown> | undefined;
|
||||
const mode = dreaming?.mode;
|
||||
return typeof mode === "string" && mode !== "off";
|
||||
const mode = typeof dreaming?.mode === "string" ? dreaming.mode.trim().toLowerCase() : "";
|
||||
if (mode === "core" || mode === "rem" || mode === "deep" || mode === "off") {
|
||||
return mode;
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
function isDreamingEnabled(configValue: Record<string, unknown> | null): boolean {
|
||||
return resolveDreamingMode(configValue) !== "off";
|
||||
}
|
||||
|
||||
function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
|
||||
if (typeof nextRunAtMs !== "number" || !Number.isFinite(nextRunAtMs)) {
|
||||
return null;
|
||||
}
|
||||
return new Date(nextRunAtMs).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
let clawhubSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -343,7 +371,25 @@ export function renderApp(state: AppViewState) {
|
||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||
const configValue =
|
||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||
const dreamingOn = isDreamingEnabled(configValue);
|
||||
const configuredDreamingMode = resolveDreamingMode(configValue);
|
||||
const dreamingMode = state.dreamingStatus?.mode ?? configuredDreamingMode;
|
||||
const dreamingOn = state.dreamingStatus?.enabled ?? isDreamingEnabled(configValue);
|
||||
const dreamingNextCycle = formatDreamNextCycle(state.dreamingStatus?.nextRunAtMs);
|
||||
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
|
||||
const refreshDreamingStatus = () => loadDreamingStatus(state);
|
||||
const applyDreamingMode = (mode: DreamingMode) => {
|
||||
if (state.dreamingModeSaving || mode === dreamingMode) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const updated = await updateDreamingMode(state, mode);
|
||||
if (!updated) {
|
||||
return;
|
||||
}
|
||||
await loadConfig(state);
|
||||
await loadDreamingStatus(state);
|
||||
})();
|
||||
};
|
||||
const basePath = normalizeBasePath(state.basePath ?? "");
|
||||
const resolvedAgentId =
|
||||
state.agentsSelectedId ??
|
||||
@@ -638,6 +684,39 @@ export function renderApp(state: AppViewState) {
|
||||
${isChat ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.tab === "dreams"
|
||||
? html`
|
||||
<div class="dreaming-header-controls">
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${dreamingLoading}
|
||||
@click=${refreshDreamingStatus}
|
||||
>
|
||||
${state.dreamingStatusLoading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
<div
|
||||
class="dreaming-header-controls__modes"
|
||||
role="group"
|
||||
aria-label="Dreaming mode"
|
||||
>
|
||||
${DREAMING_MODE_OPTIONS.map(
|
||||
(option) => html`
|
||||
<button
|
||||
class="dreaming-header-controls__mode ${dreamingMode === option.id
|
||||
? "dreaming-header-controls__mode--active"
|
||||
: ""}"
|
||||
?disabled=${dreamingLoading}
|
||||
title=${option.detail}
|
||||
@click=${() => applyDreamingMode(option.id)}
|
||||
>
|
||||
${option.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${state.lastError
|
||||
? html`<div class="pill danger">${state.lastError}</div>`
|
||||
: nothing}
|
||||
@@ -2024,11 +2103,18 @@ export function renderApp(state: AppViewState) {
|
||||
? lazyRender(lazyDreams, (m) =>
|
||||
m.renderDreams({
|
||||
active: dreamingOn,
|
||||
shortTermCount: 0,
|
||||
longTermCount: 0,
|
||||
promotedCount: 0,
|
||||
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
|
||||
longTermCount: state.dreamingStatus?.promotedTotal ?? 0,
|
||||
promotedCount: state.dreamingStatus?.promotedToday ?? 0,
|
||||
dreamingOf: null,
|
||||
nextCycle: null,
|
||||
nextCycle: dreamingNextCycle,
|
||||
mode: dreamingMode,
|
||||
statusLoading: state.dreamingStatusLoading,
|
||||
statusError: state.dreamingStatusError,
|
||||
modeSaving: state.dreamingModeSaving,
|
||||
managedCronPresent: state.dreamingStatus?.managedCronPresent ?? false,
|
||||
onRefresh: refreshDreamingStatus,
|
||||
onModeChange: applyDreamingMode,
|
||||
}),
|
||||
)
|
||||
: nothing}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
|
||||
import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
|
||||
import { loadDebug } from "./controllers/debug.ts";
|
||||
import { loadDevices } from "./controllers/devices.ts";
|
||||
import { loadDreamingStatus } from "./controllers/dreaming.ts";
|
||||
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
@@ -59,6 +60,10 @@ type SettingsHost = {
|
||||
pendingGatewayUrl?: string | null;
|
||||
systemThemeCleanup?: (() => void) | null;
|
||||
pendingGatewayToken?: string | null;
|
||||
dreamingStatusLoading: boolean;
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
|
||||
dreamingModeSaving: boolean;
|
||||
};
|
||||
|
||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
@@ -267,6 +272,10 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
await loadExecApprovals(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "dreams") {
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
await loadDreamingStatus(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "chat") {
|
||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||
scheduleChatScroll(
|
||||
|
||||
@@ -121,6 +121,10 @@ export type AppViewState = {
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
dreamingStatusLoading: boolean;
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
|
||||
dreamingModeSaving: boolean;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from "./controllers/agents.ts";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { DreamingStatus } from "./controllers/dreaming.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
import type {
|
||||
@@ -220,6 +221,10 @@ export class OpenClawApp extends LitElement {
|
||||
@state() configUiHints: ConfigUiHints = {};
|
||||
@state() configForm: Record<string, unknown> | null = null;
|
||||
@state() configFormOriginal: Record<string, unknown> | null = null;
|
||||
@state() dreamingStatusLoading = false;
|
||||
@state() dreamingStatusError: string | null = null;
|
||||
@state() dreamingStatus: DreamingStatus | null = null;
|
||||
@state() dreamingModeSaving = false;
|
||||
@state() configFormDirty = false;
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
@state() configSearchQuery = "";
|
||||
|
||||
88
ui/src/ui/controllers/dreaming.test.ts
Normal file
88
ui/src/ui/controllers/dreaming.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadDreamingStatus, updateDreamingMode, type DreamingState } from "./dreaming.ts";
|
||||
|
||||
function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn> } {
|
||||
const request = vi.fn();
|
||||
const state: DreamingState = {
|
||||
client: {
|
||||
request,
|
||||
} as unknown as DreamingState["client"],
|
||||
connected: true,
|
||||
configSnapshot: { hash: "hash-1" },
|
||||
applySessionKey: "main",
|
||||
dreamingStatusLoading: false,
|
||||
dreamingStatusError: null,
|
||||
dreamingStatus: null,
|
||||
dreamingModeSaving: false,
|
||||
lastError: null,
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
describe("dreaming controller", () => {
|
||||
it("loads and normalizes dreaming status from doctor.memory.status", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockResolvedValue({
|
||||
dreaming: {
|
||||
mode: "rem",
|
||||
enabled: true,
|
||||
frequency: "0 */6 * * *",
|
||||
timezone: "America/Los_Angeles",
|
||||
limit: 10,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
shortTermCount: 8,
|
||||
promotedTotal: 21,
|
||||
promotedToday: 2,
|
||||
managedCronPresent: true,
|
||||
nextRunAtMs: 12345,
|
||||
},
|
||||
});
|
||||
|
||||
await loadDreamingStatus(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
|
||||
expect(state.dreamingStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: "rem",
|
||||
enabled: true,
|
||||
shortTermCount: 8,
|
||||
promotedToday: 2,
|
||||
managedCronPresent: true,
|
||||
nextRunAtMs: 12345,
|
||||
}),
|
||||
);
|
||||
expect(state.dreamingStatusLoading).toBe(false);
|
||||
expect(state.dreamingStatusError).toBeNull();
|
||||
});
|
||||
|
||||
it("patches config to update dreaming mode", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockResolvedValue({ ok: true });
|
||||
|
||||
const ok = await updateDreamingMode(state, "deep");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.objectContaining({
|
||||
baseHash: "hash-1",
|
||||
sessionKey: "main",
|
||||
}),
|
||||
);
|
||||
expect(state.dreamingModeSaving).toBe(false);
|
||||
expect(state.dreamingStatusError).toBeNull();
|
||||
});
|
||||
|
||||
it("fails gracefully when config hash is missing", async () => {
|
||||
const { state, request } = createState();
|
||||
state.configSnapshot = {};
|
||||
|
||||
const ok = await updateDreamingMode(state, "core");
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.dreamingStatusError).toContain("Config hash missing");
|
||||
});
|
||||
});
|
||||
193
ui/src/ui/controllers/dreaming.ts
Normal file
193
ui/src/ui/controllers/dreaming.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ConfigSnapshot } from "../types.ts";
|
||||
|
||||
export type DreamingMode = "off" | "core" | "rem" | "deep";
|
||||
|
||||
export type DreamingStatus = {
|
||||
mode: DreamingMode;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
timezone?: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
shortTermCount: number;
|
||||
promotedTotal: number;
|
||||
promotedToday: number;
|
||||
managedCronPresent: boolean;
|
||||
nextRunAtMs?: number;
|
||||
storePath?: string;
|
||||
storeError?: string;
|
||||
};
|
||||
|
||||
type DoctorMemoryStatusPayload = {
|
||||
dreaming?: unknown;
|
||||
};
|
||||
|
||||
export type DreamingState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
applySessionKey: string;
|
||||
dreamingStatusLoading: boolean;
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: DreamingStatus | null;
|
||||
dreamingModeSaving: boolean;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeDreamingMode(value: unknown): DreamingMode {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "rem" ||
|
||||
normalized === "deep"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
function normalizeFiniteInt(value: unknown, fallback = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function normalizeFiniteScore(value: unknown, fallback = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
|
||||
const record = asRecord(raw);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const mode = normalizeDreamingMode(record.mode);
|
||||
const enabled = typeof record.enabled === "boolean" ? record.enabled : mode !== "off";
|
||||
const frequency = normalizeTrimmedString(record.frequency) ?? "";
|
||||
const timezone = normalizeTrimmedString(record.timezone);
|
||||
const nextRunRaw = record.nextRunAtMs;
|
||||
const nextRunAtMs =
|
||||
typeof nextRunRaw === "number" && Number.isFinite(nextRunRaw) ? nextRunRaw : undefined;
|
||||
|
||||
return {
|
||||
mode,
|
||||
enabled,
|
||||
frequency,
|
||||
...(timezone ? { timezone } : {}),
|
||||
limit: normalizeFiniteInt(record.limit, 0),
|
||||
minScore: normalizeFiniteScore(record.minScore, 0),
|
||||
minRecallCount: normalizeFiniteInt(record.minRecallCount, 0),
|
||||
minUniqueQueries: normalizeFiniteInt(record.minUniqueQueries, 0),
|
||||
shortTermCount: normalizeFiniteInt(record.shortTermCount, 0),
|
||||
promotedTotal: normalizeFiniteInt(record.promotedTotal, 0),
|
||||
promotedToday: normalizeFiniteInt(record.promotedToday, 0),
|
||||
managedCronPresent: record.managedCronPresent === true,
|
||||
...(nextRunAtMs !== undefined ? { nextRunAtMs } : {}),
|
||||
...(normalizeTrimmedString(record.storePath)
|
||||
? { storePath: normalizeTrimmedString(record.storePath) }
|
||||
: {}),
|
||||
...(normalizeTrimmedString(record.storeError)
|
||||
? { storeError: normalizeTrimmedString(record.storeError) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDreamingStatus(state: DreamingState): Promise<void> {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.dreamingStatusLoading) {
|
||||
return;
|
||||
}
|
||||
state.dreamingStatusLoading = true;
|
||||
state.dreamingStatusError = null;
|
||||
try {
|
||||
const payload = await state.client.request<DoctorMemoryStatusPayload>(
|
||||
"doctor.memory.status",
|
||||
{},
|
||||
);
|
||||
state.dreamingStatus = normalizeDreamingStatus(payload?.dreaming);
|
||||
} catch (err) {
|
||||
state.dreamingStatusError = String(err);
|
||||
} finally {
|
||||
state.dreamingStatusLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDreamingMode(
|
||||
state: DreamingState,
|
||||
mode: DreamingMode,
|
||||
): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
}
|
||||
if (state.dreamingModeSaving) {
|
||||
return false;
|
||||
}
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.dreamingStatusError = "Config hash missing; refresh and retry.";
|
||||
return false;
|
||||
}
|
||||
|
||||
state.dreamingModeSaving = true;
|
||||
state.dreamingStatusError = null;
|
||||
try {
|
||||
await state.client.request("config.patch", {
|
||||
baseHash,
|
||||
raw: JSON.stringify({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sessionKey: state.applySessionKey,
|
||||
note: "Dreaming mode updated from Dreams tab.",
|
||||
});
|
||||
if (state.dreamingStatus) {
|
||||
state.dreamingStatus = {
|
||||
...state.dreamingStatus,
|
||||
mode,
|
||||
enabled: mode !== "off",
|
||||
};
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
state.dreamingStatusError = message;
|
||||
state.lastError = message;
|
||||
return false;
|
||||
} finally {
|
||||
state.dreamingModeSaving = false;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ function buildProps(overrides?: Partial<DreamsProps>): DreamsProps {
|
||||
promotedCount: 12,
|
||||
dreamingOf: null,
|
||||
nextCycle: "4:00 AM",
|
||||
mode: "core",
|
||||
statusLoading: false,
|
||||
statusError: null,
|
||||
modeSaving: false,
|
||||
managedCronPresent: true,
|
||||
onRefresh: () => {},
|
||||
onModeChange: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -107,4 +114,14 @@ describe("dreams view", () => {
|
||||
const detail = container.querySelector(".dreams__status-detail span");
|
||||
expect(detail?.textContent).not.toContain("next cycle");
|
||||
});
|
||||
|
||||
it("does not render setup controls in the dreams canvas", () => {
|
||||
const container = renderInto(buildProps({ mode: "rem" }));
|
||||
expect(container.querySelector(".dreams__controls")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render canvas setup errors", () => {
|
||||
const container = renderInto(buildProps({ statusError: "patch failed" }));
|
||||
expect(container.querySelector(".dreams__controls-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { DreamingMode } from "../controllers/dreaming.ts";
|
||||
|
||||
export type DreamsProps = {
|
||||
active: boolean;
|
||||
@@ -7,6 +8,13 @@ export type DreamsProps = {
|
||||
promotedCount: number;
|
||||
dreamingOf: string | null;
|
||||
nextCycle: string | null;
|
||||
mode: DreamingMode;
|
||||
statusLoading: boolean;
|
||||
statusError: string | null;
|
||||
modeSaving: boolean;
|
||||
managedCronPresent: boolean;
|
||||
onRefresh: () => void;
|
||||
onModeChange: (mode: DreamingMode) => void;
|
||||
};
|
||||
|
||||
const DREAM_PHRASES = [
|
||||
|
||||
Reference in New Issue
Block a user