Dreaming: update multiphase stats and UI polish

This commit is contained in:
Vignesh Natarajan
2026-04-05 17:37:27 -07:00
parent 3600cecd4b
commit 7572f174e3
8 changed files with 253 additions and 23 deletions

View File

@@ -119,6 +119,8 @@ describe("doctor.memory.status", () => {
dreaming: expect.objectContaining({
enabled: true,
shortTermCount: 0,
totalSignalCount: 0,
phaseSignalCount: 0,
promotedTotal: 0,
promotedToday: 0,
phases: expect.objectContaining({
@@ -183,6 +185,18 @@ describe("doctor.memory.status", () => {
".dreams",
"short-term-recall.json",
);
const mainPhaseSignalPath = path.join(
mainWorkspaceDir,
"memory",
".dreams",
"phase-signals.json",
);
const alphaPhaseSignalPath = path.join(
alphaWorkspaceDir,
"memory",
".dreams",
"phase-signals.json",
);
await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
await fs.writeFile(
@@ -195,11 +209,15 @@ describe("doctor.memory.status", () => {
"memory:memory/2026-04-03.md:1:2": {
path: "memory/2026-04-03.md",
source: "memory",
recallCount: 2,
dailyCount: 1,
promotedAt: undefined,
},
"memory:memory/2026-04-02.md:1:2": {
path: "memory/2026-04-02.md",
source: "memory",
recallCount: 9,
dailyCount: 5,
promotedAt: recentIso,
},
},
@@ -219,11 +237,15 @@ describe("doctor.memory.status", () => {
"memory:memory/2026-04-01.md:1:2": {
path: "memory/2026-04-01.md",
source: "memory",
recallCount: 7,
dailyCount: 4,
promotedAt: olderIso,
},
"memory:memory/2026-04-04.md:1:2": {
path: "memory/2026-04-04.md",
source: "memory",
recallCount: 8,
dailyCount: 3,
promotedAt: recentIso,
},
},
@@ -233,6 +255,46 @@ describe("doctor.memory.status", () => {
)}\n`,
"utf-8",
);
await fs.writeFile(
mainPhaseSignalPath,
`${JSON.stringify(
{
version: 1,
updatedAt: recentIso,
entries: {
"memory:memory/2026-04-03.md:1:2": {
lightHits: 2,
remHits: 3,
},
"memory:memory/2026-04-02.md:1:2": {
lightHits: 9,
remHits: 9,
},
},
},
null,
2,
)}\n`,
"utf-8",
);
await fs.writeFile(
alphaPhaseSignalPath,
`${JSON.stringify(
{
version: 1,
updatedAt: recentIso,
entries: {
"memory:memory/2026-04-01.md:1:2": {
lightHits: 5,
remHits: 5,
},
},
},
null,
2,
)}\n`,
"utf-8",
);
loadConfig.mockReturnValue({
agents: {
@@ -308,6 +370,12 @@ describe("doctor.memory.status", () => {
enabled: true,
timezone: "America/Los_Angeles",
shortTermCount: 1,
recallSignalCount: 2,
dailySignalCount: 1,
totalSignalCount: 3,
phaseSignalCount: 5,
lightPhaseHitCount: 2,
remPhaseHitCount: 3,
promotedTotal: 3,
promotedToday: 2,
phases: expect.objectContaining({

View File

@@ -17,6 +17,7 @@ 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_PHASE_SIGNAL_RELATIVE_PATH = path.join("memory", ".dreams", "phase-signals.json");
const MANAGED_DEEP_SLEEP_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DEEP_SLEEP_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DEEP_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
@@ -56,11 +57,19 @@ type DoctorMemoryDreamingPayload = {
storageMode: "inline" | "separate" | "both";
separateReports: boolean;
shortTermCount: number;
recallSignalCount: number;
dailySignalCount: number;
totalSignalCount: number;
phaseSignalCount: number;
lightPhaseHitCount: number;
remPhaseHitCount: number;
promotedTotal: number;
promotedToday: number;
storePath?: string;
phaseSignalPath?: string;
lastPromotedAt?: string;
storeError?: string;
phaseSignalError?: string;
phases: {
light: DoctorMemoryLightDreamingPayload;
deep: DoctorMemoryDeepDreamingPayload;
@@ -106,11 +115,19 @@ function resolveDreamingConfig(
): Omit<
DoctorMemoryDreamingPayload,
| "shortTermCount"
| "recallSignalCount"
| "dailySignalCount"
| "totalSignalCount"
| "phaseSignalCount"
| "lightPhaseHitCount"
| "remPhaseHitCount"
| "promotedTotal"
| "promotedToday"
| "storePath"
| "phaseSignalPath"
| "lastPromotedAt"
| "storeError"
| "phaseSignalError"
> {
const resolved = resolveMemoryDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
@@ -180,31 +197,55 @@ function isShortTermMemoryPath(filePath: string): boolean {
type DreamingStoreStats = Pick<
DoctorMemoryDreamingPayload,
| "shortTermCount"
| "recallSignalCount"
| "dailySignalCount"
| "totalSignalCount"
| "phaseSignalCount"
| "lightPhaseHitCount"
| "remPhaseHitCount"
| "promotedTotal"
| "promotedToday"
| "storePath"
| "phaseSignalPath"
| "lastPromotedAt"
| "storeError"
| "phaseSignalError"
>;
function toNonNegativeInt(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) {
return 0;
}
return Math.max(0, Math.floor(num));
}
async function loadDreamingStoreStats(
workspaceDir: string,
nowMs: number,
timezone?: string,
): Promise<DreamingStoreStats> {
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
const phaseSignalPath = path.join(workspaceDir, SHORT_TERM_PHASE_SIGNAL_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 recallSignalCount = 0;
let dailySignalCount = 0;
let totalSignalCount = 0;
let phaseSignalCount = 0;
let lightPhaseHitCount = 0;
let remPhaseHitCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
let latestPromotedAt: string | undefined;
const activeKeys = new Set<string>();
for (const value of Object.values(entries)) {
for (const [entryKey, value] of Object.entries(entries)) {
const entry = asRecord(value);
if (!entry) {
continue;
@@ -217,6 +258,12 @@ async function loadDreamingStoreStats(
const promotedAt = normalizeTrimmedString(entry.promotedAt);
if (!promotedAt) {
shortTermCount += 1;
activeKeys.add(entryKey);
const recallCount = toNonNegativeInt(entry.recallCount);
const dailyCount = toNonNegativeInt(entry.dailyCount);
recallSignalCount += recallCount;
dailySignalCount += dailyCount;
totalSignalCount += recallCount + dailyCount;
continue;
}
promotedTotal += 1;
@@ -230,28 +277,74 @@ async function loadDreamingStoreStats(
}
}
let phaseSignalError: string | undefined;
try {
const phaseRaw = await fs.readFile(phaseSignalPath, "utf-8");
const parsedPhase = JSON.parse(phaseRaw) as unknown;
const phaseStore = asRecord(parsedPhase);
const phaseEntries = asRecord(phaseStore?.entries) ?? {};
for (const [key, value] of Object.entries(phaseEntries)) {
if (!activeKeys.has(key)) {
continue;
}
const phaseEntry = asRecord(value);
const lightHits = toNonNegativeInt(phaseEntry?.lightHits);
const remHits = toNonNegativeInt(phaseEntry?.remHits);
lightPhaseHitCount += lightHits;
remPhaseHitCount += remHits;
phaseSignalCount += lightHits + remHits;
}
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ENOENT") {
phaseSignalError = formatError(err);
}
}
return {
shortTermCount,
recallSignalCount,
dailySignalCount,
totalSignalCount,
phaseSignalCount,
lightPhaseHitCount,
remPhaseHitCount,
promotedTotal,
promotedToday,
storePath,
phaseSignalPath,
...(latestPromotedAt ? { lastPromotedAt: latestPromotedAt } : {}),
...(phaseSignalError ? { phaseSignalError } : {}),
};
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ENOENT") {
return {
shortTermCount: 0,
recallSignalCount: 0,
dailySignalCount: 0,
totalSignalCount: 0,
phaseSignalCount: 0,
lightPhaseHitCount: 0,
remPhaseHitCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath,
phaseSignalPath,
};
}
return {
shortTermCount: 0,
recallSignalCount: 0,
dailySignalCount: 0,
totalSignalCount: 0,
phaseSignalCount: 0,
lightPhaseHitCount: 0,
remPhaseHitCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath,
phaseSignalPath,
storeError: formatError(err),
};
}
@@ -259,23 +352,43 @@ async function loadDreamingStoreStats(
function mergeDreamingStoreStats(stats: DreamingStoreStats[]): DreamingStoreStats {
let shortTermCount = 0;
let recallSignalCount = 0;
let dailySignalCount = 0;
let totalSignalCount = 0;
let phaseSignalCount = 0;
let lightPhaseHitCount = 0;
let remPhaseHitCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
let lastPromotedAt: string | undefined;
const storePaths = new Set<string>();
const phaseSignalPaths = new Set<string>();
const storeErrors: string[] = [];
const phaseSignalErrors: string[] = [];
for (const stat of stats) {
shortTermCount += stat.shortTermCount;
recallSignalCount += stat.recallSignalCount;
dailySignalCount += stat.dailySignalCount;
totalSignalCount += stat.totalSignalCount;
phaseSignalCount += stat.phaseSignalCount;
lightPhaseHitCount += stat.lightPhaseHitCount;
remPhaseHitCount += stat.remPhaseHitCount;
promotedTotal += stat.promotedTotal;
promotedToday += stat.promotedToday;
if (stat.storePath) {
storePaths.add(stat.storePath);
}
if (stat.phaseSignalPath) {
phaseSignalPaths.add(stat.phaseSignalPath);
}
if (stat.storeError) {
storeErrors.push(stat.storeError);
}
if (stat.phaseSignalError) {
phaseSignalErrors.push(stat.phaseSignalError);
}
const promotedAtMs = stat.lastPromotedAt ? Date.parse(stat.lastPromotedAt) : Number.NaN;
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
latestPromotedAtMs = promotedAtMs;
@@ -285,15 +398,27 @@ function mergeDreamingStoreStats(stats: DreamingStoreStats[]): DreamingStoreStat
return {
shortTermCount,
recallSignalCount,
dailySignalCount,
totalSignalCount,
phaseSignalCount,
lightPhaseHitCount,
remPhaseHitCount,
promotedTotal,
promotedToday,
...(storePaths.size === 1 ? { storePath: [...storePaths][0] } : {}),
...(phaseSignalPaths.size === 1 ? { phaseSignalPath: [...phaseSignalPaths][0] } : {}),
...(lastPromotedAt ? { lastPromotedAt } : {}),
...(storeErrors.length === 1
? { storeError: storeErrors[0] }
: storeErrors.length > 1
? { storeError: `${storeErrors.length} dreaming stores had read errors.` }
: {}),
...(phaseSignalErrors.length === 1
? { phaseSignalError: phaseSignalErrors[0] }
: phaseSignalErrors.length > 1
? { phaseSignalError: `${phaseSignalErrors.length} phase signal stores had read errors.` }
: {}),
};
}
@@ -472,6 +597,12 @@ export const doctorHandlers: GatewayRequestHandlers = {
)
: {
shortTermCount: 0,
recallSignalCount: 0,
dailySignalCount: 0,
totalSignalCount: 0,
phaseSignalCount: 0,
lightPhaseHitCount: 0,
remPhaseHitCount: 0,
promotedTotal: 0,
promotedToday: 0,
};

View File

@@ -106,31 +106,33 @@
animation: dreams-float-z 4s ease-out infinite;
}
.dreams__z:nth-child(1) {
/* Z's originate from the lobster's head and float up-right at an angle.
Use nth-of-type because Z's are <span> while stars/moon/glow are <div>. */
.dreams__z:nth-of-type(1) {
font-size: 14px;
right: calc(50% - 100px);
top: calc(50% - 80px);
left: calc(50% + 70px);
top: calc(50% - 90px);
animation-delay: 0s;
}
.dreams__z:nth-child(2) {
.dreams__z:nth-of-type(2) {
font-size: 20px;
right: calc(50% - 130px);
top: calc(50% - 120px);
left: calc(50% + 100px);
top: calc(50% - 130px);
animation-delay: 1.2s;
}
.dreams__z:nth-child(3) {
.dreams__z:nth-of-type(3) {
font-size: 28px;
right: calc(50% - 160px);
top: calc(50% - 170px);
left: calc(50% + 130px);
top: calc(50% - 175px);
animation-delay: 2.4s;
}
@keyframes dreams-float-z {
0% {
opacity: 0;
transform: translateY(10px) rotate(-5deg);
transform: translate(0, 0) rotate(-10deg);
}
15% {
opacity: 0.7;
@@ -140,7 +142,7 @@
}
100% {
opacity: 0;
transform: translateY(-40px) rotate(10deg);
transform: translate(20px, -40px) rotate(15deg);
}
}
@@ -167,7 +169,7 @@
.dreams__bubble {
position: absolute;
top: calc(50% - 200px);
top: calc(50% - 260px);
left: calc(50% - 200px);
padding: 16px 20px;
background: var(--accent-subtle);

View File

@@ -2103,7 +2103,8 @@ export function renderApp(state: AppViewState) {
m.renderDreaming({
active: dreamingOn,
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
longTermCount: state.dreamingStatus?.promotedTotal ?? 0,
totalSignalCount: state.dreamingStatus?.totalSignalCount ?? 0,
phaseSignalCount: state.dreamingStatus?.phaseSignalCount ?? 0,
promotedCount: state.dreamingStatus?.promotedToday ?? 0,
dreamingOf: null,
nextCycle: dreamingNextCycle,

View File

@@ -39,6 +39,12 @@ describe("dreaming controller", () => {
storageMode: "inline",
separateReports: false,
shortTermCount: 8,
recallSignalCount: 14,
dailySignalCount: 6,
totalSignalCount: 20,
phaseSignalCount: 11,
lightPhaseHitCount: 7,
remPhaseHitCount: 4,
promotedTotal: 21,
promotedToday: 2,
phases: {
@@ -82,6 +88,8 @@ describe("dreaming controller", () => {
expect.objectContaining({
enabled: true,
shortTermCount: 8,
totalSignalCount: 20,
phaseSignalCount: 11,
promotedToday: 2,
phases: expect.objectContaining({
deep: expect.objectContaining({

View File

@@ -38,10 +38,18 @@ export type DreamingStatus = {
storageMode: "inline" | "separate" | "both";
separateReports: boolean;
shortTermCount: number;
recallSignalCount: number;
dailySignalCount: number;
totalSignalCount: number;
phaseSignalCount: number;
lightPhaseHitCount: number;
remPhaseHitCount: number;
promotedTotal: number;
promotedToday: number;
storePath?: string;
phaseSignalPath?: string;
storeError?: string;
phaseSignalError?: string;
phases: {
light: LightDreamingStatus;
deep: DeepDreamingStatus;
@@ -142,7 +150,9 @@ function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
const remRecord = asRecord(phasesRecord?.rem);
const timezone = normalizeTrimmedString(record.timezone);
const storePath = normalizeTrimmedString(record.storePath);
const phaseSignalPath = normalizeTrimmedString(record.phaseSignalPath);
const storeError = normalizeTrimmedString(record.storeError);
const phaseSignalError = normalizeTrimmedString(record.phaseSignalError);
return {
enabled: normalizeBoolean(record.enabled, false),
@@ -151,10 +161,18 @@ function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
storageMode: normalizeStorageMode(record.storageMode),
separateReports: normalizeBoolean(record.separateReports, false),
shortTermCount: normalizeFiniteInt(record.shortTermCount, 0),
recallSignalCount: normalizeFiniteInt(record.recallSignalCount, 0),
dailySignalCount: normalizeFiniteInt(record.dailySignalCount, 0),
totalSignalCount: normalizeFiniteInt(record.totalSignalCount, 0),
phaseSignalCount: normalizeFiniteInt(record.phaseSignalCount, 0),
lightPhaseHitCount: normalizeFiniteInt(record.lightPhaseHitCount, 0),
remPhaseHitCount: normalizeFiniteInt(record.remPhaseHitCount, 0),
promotedTotal: normalizeFiniteInt(record.promotedTotal, 0),
promotedToday: normalizeFiniteInt(record.promotedToday, 0),
...(storePath ? { storePath } : {}),
...(phaseSignalPath ? { phaseSignalPath } : {}),
...(storeError ? { storeError } : {}),
...(phaseSignalError ? { phaseSignalError } : {}),
phases: {
light: {
...normalizePhaseStatusBase(lightRecord),

View File

@@ -8,7 +8,8 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
return {
active: true,
shortTermCount: 47,
longTermCount: 182,
totalSignalCount: 182,
phaseSignalCount: 29,
promotedCount: 12,
dreamingOf: null,
nextCycle: "4:00 AM",
@@ -64,7 +65,7 @@ describe("dreaming view", () => {
expect(values.length).toBe(3);
expect(values[0]?.textContent).toBe("47");
expect(values[1]?.textContent).toBe("182");
expect(values[2]?.textContent).toBe("12");
expect(values[2]?.textContent).toBe("29");
});
it("shows dream bubble when active", () => {

View File

@@ -55,7 +55,8 @@ function parseDiaryEntries(raw: string): DiaryEntry[] {
export type DreamingProps = {
active: boolean;
shortTermCount: number;
longTermCount: number;
totalSignalCount: number;
phaseSignalCount: number;
promotedCount: number;
dreamingOf: string | null;
nextCycle: string | null;
@@ -245,11 +246,11 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
</div>
<div
class="dreams__bubble-dot"
style="top: calc(50% - 100px); left: calc(50% - 80px); width: 12px; height: 12px; animation-delay: 0.2s;"
style="top: calc(50% - 160px); left: calc(50% - 120px); 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;"
style="top: calc(50% - 120px); left: calc(50% - 90px); width: 8px; height: 8px; animation-delay: 0.4s;"
></div>
`
: nothing}
@@ -284,16 +285,16 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
<div class="dreams__stat-divider"></div>
<div class="dreams__stat">
<span class="dreams__stat-value" style="color: var(--accent);"
>${props.longTermCount}</span
>${props.totalSignalCount}</span
>
<span class="dreams__stat-label">Long-term</span>
<span class="dreams__stat-label">Signals</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
>${props.phaseSignalCount}</span
>
<span class="dreams__stat-label">Promoted Today</span>
<span class="dreams__stat-label">Phase Hits</span>
</div>
</div>