Dreaming: surface grounded scene lane (#63395)

Merged via squash.

Prepared head SHA: 0c7f586f32
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-09 00:24:47 +02:00
committed by GitHub
parent bd7801eefa
commit d514f4de83
15 changed files with 202 additions and 26 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- QA/lab: add character-vibes evaluation reports with model selection and parallel runs so live QA can compare candidate behavior faster.
- Plugins/provider-auth: let provider manifests declare `providerAuthAliases` so provider variants can share env vars, auth profiles, config-backed auth, and API-key onboarding choices without core-specific wiring.
- iOS: pin release versioning to an explicit CalVer in `apps/ios/version.json`, keep TestFlight iteration on the same short version until maintainers intentionally promote the next gateway version, and add the documented `pnpm ios:version:pin -- --from-gateway` workflow for release trains. (#63001) Thanks @ngutman.
- Control UI/dreaming: surface grounded historical replay as its own Scene lane with grounded-led promotion hints and a safe clear-grounded action for staged backfill-only signals. (#63395) Thanks @mbelinky.
### Fixes

View File

@@ -88,7 +88,12 @@ There is also a grounded historical backfill lane for review and recovery work:
- `memory rem-backfill --path ... --stage-short-term` stages grounded durable candidates into the same short-term evidence store the normal deep phase already uses.
- `memory rem-backfill --rollback` and `--rollback-short-term` remove those staged backfill artifacts without touching ordinary diary entries or live short-term recall.
The Control UI exposes the same diary backfill/reset flow so you can inspect results in the Dreams scene before deciding whether the grounded candidates deserve promotion.
The Control UI exposes the same diary backfill/reset flow so you can inspect
results in the Dreams scene before deciding whether the grounded candidates
deserve promotion. The Scene also shows a distinct grounded lane so you can see
which staged short-term entries came from historical replay, which promoted
items were grounded-led, and clear only grounded-only staged entries without
touching ordinary live short-term state.
## Deep ranking signals
@@ -216,8 +221,9 @@ When enabled, the Gateway **Dreams** tab shows:
- current dreaming enabled state
- phase-level status and managed-sweep presence
- short-term, long-term, and promoted-today counts
- short-term, grounded, signal, and promoted-today counts
- next scheduled run timing
- a distinct grounded Scene lane for staged historical replay entries
- an expandable Dream Diary reader backed by `doctor.memory.dreamDiary`
## Related

View File

@@ -95,9 +95,10 @@ cat ~/.openclaw/openclaw.json
## Dreams UI backfill and reset
The Control UI Dreams scene includes **Backfill** and **Reset** actions for the
grounded diary workflow. These actions use gateway doctor-style RPC methods, but
they are **not** part of `openclaw doctor` CLI repair/migration.
The Control UI Dreams scene includes **Backfill**, **Reset**, and **Clear Grounded**
actions for the grounded dreaming workflow. These actions use gateway
doctor-style RPC methods, but they are **not** part of `openclaw doctor` CLI
repair/migration.
What they do:
@@ -105,13 +106,16 @@ What they do:
workspace, runs the grounded REM diary pass, and writes reversible backfill
entries into `DREAMS.md`.
- **Reset** removes only those marked backfill diary entries from `DREAMS.md`.
- **Clear Grounded** removes only staged grounded-only short-term entries that
came from historical replay and have not accumulated live recall or daily
support yet.
What they do **not** do by themselves:
- they do not edit `MEMORY.md`
- they do not run full doctor migrations
- they do not automatically stage grounded candidates into the live short-term
promotion store
promotion store unless you explicitly run the staged CLI path first
If you want grounded historical replay to influence the normal deep promotion
lane, use the CLI flow instead:

View File

@@ -17,6 +17,7 @@ export { checkQmdBinaryAvailability } from "openclaw/plugin-sdk/memory-core-host
export { hasConfiguredMemorySecretInput } from "openclaw/plugin-sdk/memory-core-host-secret";
export {
auditShortTermPromotionArtifacts,
removeGroundedShortTermCandidates,
repairShortTermPromotionArtifacts,
} from "./src/short-term-promotion.js";
export type { BuiltinMemoryEmbeddingProviderDoctorMetadata } from "./src/memory/provider-adapters.js";

View File

@@ -138,6 +138,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"node.pending.enqueue",
"doctor.memory.backfillDreamDiary",
"doctor.memory.resetDreamDiary",
"doctor.memory.resetGroundedShortTerm",
],
[ADMIN_SCOPE]: [
"channels.logout",

View File

@@ -7,6 +7,7 @@ const BASE_METHODS = [
"doctor.memory.dreamDiary",
"doctor.memory.backfillDreamDiary",
"doctor.memory.resetDreamDiary",
"doctor.memory.resetGroundedShortTerm",
"logs.tail",
"channels.status",
"channels.logout",

View File

@@ -3,3 +3,4 @@ export {
previewGroundedRemMarkdown,
writeBackfillDiaryEntries,
} from "../../../extensions/memory-core/api.js";
export { removeGroundedShortTermCandidates } from "../../../extensions/memory-core/runtime-api.js";

View File

@@ -18,6 +18,7 @@ const getMemorySearchManager = vi.hoisted(() => vi.fn());
const previewGroundedRemMarkdown = vi.hoisted(() => vi.fn());
const writeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeGroundedShortTermCandidates = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", () => ({
loadConfig,
@@ -40,6 +41,7 @@ vi.mock("./doctor.memory-core-runtime.js", () => ({
previewGroundedRemMarkdown,
writeBackfillDiaryEntries,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
}));
import { doctorHandlers } from "./doctor.js";
@@ -100,6 +102,17 @@ const invokeDoctorMemoryResetDreamDiary = async (respond: ReturnType<typeof vi.f
});
};
const invokeDoctorMemoryResetGroundedShortTerm = async (respond: ReturnType<typeof vi.fn>) => {
await doctorHandlers["doctor.memory.resetGroundedShortTerm"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
};
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
expect(respond).toHaveBeenCalledWith(
true,
@@ -121,6 +134,10 @@ describe("doctor.memory.status", () => {
resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw");
resolveMemorySearchConfig.mockReset().mockReturnValue({ enabled: true });
getMemorySearchManager.mockReset();
previewGroundedRemMarkdown.mockReset();
writeBackfillDiaryEntries.mockReset();
removeBackfillDiaryEntries.mockReset();
removeGroundedShortTermCandidates.mockReset();
});
it("returns gateway embedding probe status for the default agent", async () => {
@@ -701,6 +718,32 @@ describe("doctor.memory.status", () => {
});
});
describe("doctor.memory dream actions", () => {
it("clears grounded-only staged short-term entries without touching the diary", async () => {
resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw");
removeGroundedShortTermCandidates.mockResolvedValue({
removed: 3,
storePath: "/tmp/openclaw/memory/.dreams/short-term-recall.json",
});
const respond = vi.fn();
await invokeDoctorMemoryResetGroundedShortTerm(respond);
expect(removeGroundedShortTermCandidates).toHaveBeenCalledWith({
workspaceDir: "/tmp/openclaw",
});
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
action: "resetGroundedShortTerm",
removedShortTermEntries: 3,
},
undefined,
);
});
});
describe("doctor.memory.dreamDiary", () => {
beforeEach(() => {
loadConfig.mockClear();

View File

@@ -16,6 +16,7 @@ import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { formatError } from "../server-utils.js";
import {
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
previewGroundedRemMarkdown,
writeBackfillDiaryEntries,
} from "./doctor.memory-core-runtime.js";
@@ -122,15 +123,16 @@ export type DoctorMemoryDreamDiaryPayload = {
updatedAtMs?: number;
};
export type DoctorMemoryDreamDiaryActionPayload = {
export type DoctorMemoryDreamActionPayload = {
agentId: string;
path: string;
action: "backfill" | "reset";
found: boolean;
action: "backfill" | "reset" | "resetGroundedShortTerm";
path?: string;
found?: boolean;
scannedFiles?: number;
written?: number;
replaced?: number;
removedEntries?: number;
removedShortTermEntries?: number;
};
function extractIsoDayFromPath(filePath: string): string | null {
@@ -880,7 +882,7 @@ export const doctorHandlers: GatewayRequestHandlers = {
const sourceFiles = await listWorkspaceDailyFiles(memoryDir);
if (sourceFiles.length === 0) {
const dreamDiary = await readDreamDiary(workspaceDir);
const payload: DoctorMemoryDreamDiaryActionPayload = {
const payload: DoctorMemoryDreamActionPayload = {
agentId,
path: dreamDiary.path,
action: "backfill",
@@ -919,7 +921,7 @@ export const doctorHandlers: GatewayRequestHandlers = {
timezone: remConfig.timezone,
});
const dreamDiary = await readDreamDiary(workspaceDir);
const payload: DoctorMemoryDreamDiaryActionPayload = {
const payload: DoctorMemoryDreamActionPayload = {
agentId,
path: dreamDiary.path,
action: "backfill",
@@ -936,7 +938,7 @@ export const doctorHandlers: GatewayRequestHandlers = {
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const removed = await removeBackfillDiaryEntries({ workspaceDir });
const dreamDiary = await readDreamDiary(workspaceDir);
const payload: DoctorMemoryDreamDiaryActionPayload = {
const payload: DoctorMemoryDreamActionPayload = {
agentId,
path: dreamDiary.path,
action: "reset",
@@ -945,4 +947,16 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.resetGroundedShortTerm": async ({ respond }) => {
const cfg = loadConfig();
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const removed = await removeGroundedShortTermCandidates({ workspaceDir });
const payload: DoctorMemoryDreamActionPayload = {
agentId,
action: "resetGroundedShortTerm",
removedShortTermEntries: removed.removed,
};
respond(true, payload, undefined);
},
};

View File

@@ -293,19 +293,24 @@ export const en: TranslationMap = {
scene: {
backfill: "Backfill",
reset: "Reset",
clearGrounded: "Clear Grounded",
working: "Working…",
},
stats: {
shortTerm: "Short-term",
grounded: "Grounded",
signals: "Signals",
promoted: "Promoted",
phaseHits: "Phase Hits",
},
trace: {
shortTerm: "Short-term",
grounded: "Grounded",
signals: "Signals",
promoted: "Promoted",
groundedLed: "grounded-led",
emptyShortTerm: "No active short-term items.",
emptyGrounded: "No staged grounded items.",
emptySignals: "No active signals.",
emptyPromoted: "Nothing promoted yet today.",
},

View File

@@ -69,6 +69,7 @@ import {
backfillDreamDiary,
loadDreamDiary,
loadDreamingStatus,
resetGroundedShortTerm,
resetDreamDiary,
resolveConfiguredDreaming,
updateDreamingEnabled,
@@ -2118,6 +2119,7 @@ export function renderApp(state: AppViewState) {
? renderDreaming({
active: dreamingOn,
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
groundedSignalCount: state.dreamingStatus?.groundedSignalCount ?? 0,
totalSignalCount: state.dreamingStatus?.totalSignalCount ?? 0,
promotedCount: state.dreamingStatus?.promotedToday ?? 0,
phaseSignalCount: state.dreamingStatus?.phaseSignalCount ?? 0,
@@ -2139,6 +2141,7 @@ export function renderApp(state: AppViewState) {
onRefreshDiary: () => loadDreamDiary(state),
onBackfillDiary: () => backfillDreamDiary(state),
onResetDiary: () => resetDreamDiary(state),
onResetGroundedShortTerm: () => resetGroundedShortTerm(state),
onToggleEnabled: applyDreamingEnabled,
onRequestUpdate: requestHostUpdate,
})

View File

@@ -3,6 +3,7 @@ import {
backfillDreamDiary,
loadDreamDiary,
loadDreamingStatus,
resetGroundedShortTerm,
resetDreamDiary,
resolveConfiguredDreaming,
updateDreamingEnabled,
@@ -501,4 +502,27 @@ describe("dreaming controller", () => {
expect(state.dreamDiaryContent).toBeNull();
expect(state.dreamDiaryActionLoading).toBe(false);
});
it("clears grounded staged entries and reloads only dreaming status", async () => {
const { state, request } = createState();
state.dreamDiaryContent = "keep existing diary";
request.mockImplementation(async (method: string) => {
if (method === "doctor.memory.resetGroundedShortTerm") {
return { action: "resetGroundedShortTerm", removedShortTermEntries: 2 };
}
if (method === "doctor.memory.status") {
return { dreaming: null };
}
return {};
});
const ok = await resetGroundedShortTerm(state);
expect(ok).toBe(true);
expect(request).toHaveBeenCalledWith("doctor.memory.resetGroundedShortTerm", {});
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
expect(request).not.toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
expect(state.dreamDiaryContent).toBe("keep existing diary");
expect(state.dreamDiaryActionLoading).toBe(false);
});
});

View File

@@ -89,11 +89,12 @@ type DoctorMemoryDreamDiaryPayload = {
content?: unknown;
};
type DoctorMemoryDreamDiaryActionPayload = {
type DoctorMemoryDreamActionPayload = {
action?: unknown;
removedEntries?: unknown;
written?: unknown;
replaced?: unknown;
removedShortTermEntries?: unknown;
};
export type DreamingState = {
@@ -344,7 +345,13 @@ export async function loadDreamDiary(state: DreamingState): Promise<void> {
async function runDreamDiaryAction(
state: DreamingState,
method: "doctor.memory.backfillDreamDiary" | "doctor.memory.resetDreamDiary",
method:
| "doctor.memory.backfillDreamDiary"
| "doctor.memory.resetDreamDiary"
| "doctor.memory.resetGroundedShortTerm",
options?: {
reloadDiary?: boolean;
},
): Promise<boolean> {
if (!state.client || !state.connected || state.dreamDiaryActionLoading) {
return false;
@@ -353,8 +360,10 @@ async function runDreamDiaryAction(
state.dreamingStatusError = null;
state.dreamDiaryError = null;
try {
await state.client.request<DoctorMemoryDreamDiaryActionPayload>(method, {});
await loadDreamDiary(state);
await state.client.request<DoctorMemoryDreamActionPayload>(method, {});
if (options?.reloadDiary !== false) {
await loadDreamDiary(state);
}
await loadDreamingStatus(state);
return true;
} catch (err) {
@@ -375,6 +384,12 @@ export async function resetDreamDiary(state: DreamingState): Promise<boolean> {
return runDreamDiaryAction(state, "doctor.memory.resetDreamDiary");
}
export async function resetGroundedShortTerm(state: DreamingState): Promise<boolean> {
return runDreamDiaryAction(state, "doctor.memory.resetGroundedShortTerm", {
reloadDiary: false,
});
}
async function writeDreamingPatch(
state: DreamingState,
patch: Record<string, unknown>,

View File

@@ -8,6 +8,7 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
return {
active: true,
shortTermCount: 47,
groundedSignalCount: 9,
totalSignalCount: 182,
promotedCount: 12,
phaseSignalCount: 29,
@@ -52,8 +53,8 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
snippet: "Use the Happy Together calendar for flights.",
recallCount: 3,
dailyCount: 2,
groundedCount: 0,
totalSignalCount: 5,
groundedCount: 4,
totalSignalCount: 9,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
@@ -76,6 +77,7 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
onRefreshDiary: () => {},
onBackfillDiary: () => {},
onResetDiary: () => {},
onResetGroundedShortTerm: () => {},
onToggleEnabled: () => {},
...overrides,
};
@@ -114,36 +116,44 @@ describe("dreaming view", () => {
it("displays memory stats", () => {
const container = renderInto(buildProps());
const values = container.querySelectorAll(".dreams__stat-value");
expect(values.length).toBe(3);
expect(values.length).toBe(4);
expect(values[0]?.textContent).toBe("47");
expect(values[1]?.textContent).toBe("182");
expect(values[2]?.textContent).toBe("12");
expect(values[1]?.textContent).toBe("9");
expect(values[2]?.textContent).toBe("182");
expect(values[3]?.textContent).toBe("12");
});
it("renders short-term, signals, and promoted detail sections", () => {
it("renders short-term, grounded, signals, and promoted detail sections", () => {
const container = renderInto(buildProps());
const titles = [...container.querySelectorAll(".dreams__trace-title")].map((node) =>
node.textContent?.trim(),
);
expect(titles).toEqual(["Short-term", "Signals", "Promoted"]);
expect(titles).toEqual(["Short-term", "Grounded", "Signals", "Promoted"]);
expect(
container.querySelector('[data-kind="shortTerm"] .dreams__trace-snippet')?.textContent,
).toContain("Emma prefers shorter");
expect(
container.querySelector('[data-kind="grounded"] .dreams__trace-meta')?.textContent,
).toContain("1 grounded");
expect(
container.querySelector('[data-kind="signals"] .dreams__trace-meta')?.textContent,
).toContain("3 signals");
expect(
container.querySelector('[data-kind="promoted"] .dreams__trace-source')?.textContent,
).toContain("memory/2026-04-04.md:4-5");
expect(
container.querySelector('[data-kind="promoted"] .dreams__trace-meta')?.textContent,
).toContain("grounded-led");
});
it("renders scene backfill and reset controls", () => {
it("renders scene backfill, reset, and clear grounded controls", () => {
const container = renderInto(buildProps());
const buttons = [...container.querySelectorAll("button")].map((node) =>
node.textContent?.trim(),
);
expect(buttons).toContain("Backfill");
expect(buttons).toContain("Reset");
expect(buttons).toContain("Clear Grounded");
});
it("shows dream bubble when active", () => {

View File

@@ -258,6 +258,7 @@ function renderDiaryNavigator(
export type DreamingProps = {
active: boolean;
shortTermCount: number;
groundedSignalCount: number;
totalSignalCount: number;
promotedCount: number;
phaseSignalCount: number;
@@ -324,6 +325,7 @@ export type DreamingProps = {
onRefreshDiary: () => void;
onBackfillDiary: () => void;
onResetDiary: () => void;
onResetGroundedShortTerm: () => void;
onToggleEnabled: (enabled: boolean) => void;
onRequestUpdate?: () => void;
};
@@ -473,6 +475,7 @@ export function renderDreaming(props: DreamingProps) {
// ── Scene renderer ────────────────────────────────────────────────────
function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
const groundedEntries = props.shortTermEntries.filter((entry) => entry.groundedCount > 0);
return html`
<section class="dreams ${idle ? "dreams--idle" : ""}">
${STARS.map(
@@ -548,6 +551,13 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
>
${t("dreaming.scene.reset")}
</button>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
@click=${() => props.onResetGroundedShortTerm()}
>
${t("dreaming.scene.clearGrounded")}
</button>
</div>
<div class="dreams__stats">
@@ -558,6 +568,13 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
<span class="dreams__stat-label">${t("dreaming.stats.shortTerm")}</span>
</div>
<div class="dreams__stat-divider"></div>
<div class="dreams__stat">
<span class="dreams__stat-value" style="color: var(--accent-muted);"
>${props.groundedSignalCount}</span
>
<span class="dreams__stat-label">${t("dreaming.stats.grounded")}</span>
</div>
<div class="dreams__stat-divider"></div>
<div class="dreams__stat">
<span class="dreams__stat-value" style="color: var(--accent);"
>${props.totalSignalCount}</span
@@ -591,6 +608,21 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
.filter(Boolean)
.join(" · "),
})}
${renderTraceSection("grounded", groundedEntries, {
count: groundedEntries.length,
emptyKey: "dreaming.trace.emptyGrounded",
meta: (entry) =>
[
`${entry.groundedCount} grounded`,
entry.recallCount > 0
? `${entry.recallCount} recall${entry.recallCount === 1 ? "" : "s"}`
: null,
entry.dailyCount > 0 ? `${entry.dailyCount} daily` : null,
isGroundedLed(entry) ? t("dreaming.trace.groundedLed") : null,
]
.filter(Boolean)
.join(" · "),
})}
${renderTraceSection("signals", props.signalEntries, {
count: props.totalSignalCount,
emptyKey: "dreaming.trace.emptySignals",
@@ -610,6 +642,8 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
meta: (entry) =>
[
entry.promotedAt ? formatCompactDateTime(entry.promotedAt) : null,
entry.groundedCount > 0 ? `${entry.groundedCount} grounded` : null,
isGroundedLed(entry) ? t("dreaming.trace.groundedLed") : null,
entry.totalSignalCount > 0
? `${entry.totalSignalCount} signal${entry.totalSignalCount === 1 ? "" : "s"} before promote`
: null,
@@ -643,8 +677,21 @@ function formatCompactDateTime(value: string): string {
});
}
function isGroundedLed(
entry: Pick<
DreamingProps["shortTermEntries"][number],
"groundedCount" | "recallCount" | "dailyCount"
>,
): boolean {
return (
entry.groundedCount > 0 &&
entry.groundedCount >= entry.recallCount &&
entry.groundedCount >= entry.dailyCount
);
}
function renderTraceSection(
kind: "shortTerm" | "signals" | "promoted",
kind: "shortTerm" | "grounded" | "signals" | "promoted",
entries: DreamingProps["shortTermEntries"],
options: {
count: number;