mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
Fix dreaming replay, repair polluted artifacts, and gate wiki tabs (#65138)
* fix(active-memory): preserve parent channel context for recall runs * fix(active-memory): keep recall runs on the resolved channel * fix(active-memory): prefer resolved recall channel over wrapper hints * fix(active-memory): trust explicit recall channel hints * fix(active-memory): rank recall channel fallbacks by trust * Fix dreaming replay and recovery flows * fix: prevent dreaming event loss and diary write races * chore: add changelog entry for memory fixes * fix: harden dreaming repair and diary writes * fix: harden dreaming artifact archive naming
This commit is contained in:
@@ -293,8 +293,10 @@ export const en: TranslationMap = {
|
||||
},
|
||||
scene: {
|
||||
backfill: "Backfill",
|
||||
dedupeDiary: "Dedupe Diary",
|
||||
reset: "Reset",
|
||||
clearGrounded: "Clear Replayed",
|
||||
repairCache: "Repair Dream Cache",
|
||||
working: "Working…",
|
||||
},
|
||||
phase: {
|
||||
|
||||
@@ -73,10 +73,13 @@ import {
|
||||
} from "./controllers/devices.ts";
|
||||
import {
|
||||
backfillDreamDiary,
|
||||
copyDreamingArchivePath,
|
||||
dedupeDreamDiary,
|
||||
loadDreamDiary,
|
||||
loadDreamingStatus,
|
||||
loadWikiImportInsights,
|
||||
loadWikiMemoryPalace,
|
||||
repairDreamingArtifacts,
|
||||
resetGroundedShortTerm,
|
||||
resetDreamDiary,
|
||||
resolveConfiguredDreaming,
|
||||
@@ -233,6 +236,32 @@ function uniquePreserveOrder(values: string[]): string[] {
|
||||
return output;
|
||||
}
|
||||
|
||||
function isPluginExplicitlyEnabled(
|
||||
configSnapshot: AppViewState["configSnapshot"],
|
||||
pluginId: string,
|
||||
): boolean {
|
||||
const config = configSnapshot?.config;
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return true;
|
||||
}
|
||||
const plugins =
|
||||
"plugins" in config && config.plugins && typeof config.plugins === "object"
|
||||
? (config.plugins as Record<string, unknown>)
|
||||
: null;
|
||||
if (plugins?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const entries =
|
||||
plugins && "entries" in plugins && plugins.entries && typeof plugins.entries === "object"
|
||||
? (plugins.entries as Record<string, unknown>)
|
||||
: null;
|
||||
const entry = entries?.[pluginId];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return true;
|
||||
}
|
||||
return (entry as { enabled?: unknown }).enabled !== false;
|
||||
}
|
||||
|
||||
type DismissedUpdateBanner = {
|
||||
latestVersion: string;
|
||||
channel: string | null;
|
||||
@@ -1985,9 +2014,12 @@ export function renderApp(state: AppViewState) {
|
||||
modeSaving: state.dreamingModeSaving,
|
||||
dreamDiaryLoading: state.dreamDiaryLoading,
|
||||
dreamDiaryActionLoading: state.dreamDiaryActionLoading,
|
||||
dreamDiaryActionMessage: state.dreamDiaryActionMessage,
|
||||
dreamDiaryActionArchivePath: state.dreamDiaryActionArchivePath,
|
||||
dreamDiaryError: state.dreamDiaryError,
|
||||
dreamDiaryPath: state.dreamDiaryPath,
|
||||
dreamDiaryContent: state.dreamDiaryContent,
|
||||
memoryWikiEnabled: isPluginExplicitlyEnabled(state.configSnapshot, "memory-wiki"),
|
||||
wikiImportInsightsLoading: state.wikiImportInsightsLoading,
|
||||
wikiImportInsightsError: state.wikiImportInsightsError,
|
||||
wikiImportInsights: state.wikiImportInsights,
|
||||
@@ -1998,10 +2030,16 @@ export function renderApp(state: AppViewState) {
|
||||
onRefreshDiary: () => loadDreamDiary(state),
|
||||
onRefreshImports: () => loadWikiImportInsights(state),
|
||||
onRefreshMemoryPalace: () => loadWikiMemoryPalace(state),
|
||||
onOpenConfig: () => openConfigFile(state),
|
||||
onOpenWikiPage: (lookup: string) => openWikiPage(lookup),
|
||||
onBackfillDiary: () => backfillDreamDiary(state),
|
||||
onCopyDreamingArchivePath: () => {
|
||||
void copyDreamingArchivePath(state);
|
||||
},
|
||||
onDedupeDreamDiary: () => dedupeDreamDiary(state),
|
||||
onResetDiary: () => resetDreamDiary(state),
|
||||
onResetGroundedShortTerm: () => resetGroundedShortTerm(state),
|
||||
onRepairDreamingArtifacts: () => repairDreamingArtifacts(state),
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
})
|
||||
: nothing}
|
||||
|
||||
@@ -70,6 +70,8 @@ type SettingsHost = {
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryActionLoading: boolean;
|
||||
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
|
||||
dreamDiaryActionArchivePath: string | null;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
@@ -165,6 +167,8 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
dreamingModeSaving: false,
|
||||
dreamDiaryLoading: false,
|
||||
dreamDiaryActionLoading: false,
|
||||
dreamDiaryActionMessage: null,
|
||||
dreamDiaryActionArchivePath: null,
|
||||
dreamDiaryError: null,
|
||||
dreamDiaryPath: null,
|
||||
dreamDiaryContent: null,
|
||||
|
||||
@@ -136,6 +136,8 @@ export type AppViewState = {
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryActionLoading: boolean;
|
||||
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
|
||||
dreamDiaryActionArchivePath: string | null;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
|
||||
@@ -238,6 +238,8 @@ export class OpenClawApp extends LitElement {
|
||||
@state() dreamingModeSaving = false;
|
||||
@state() dreamDiaryLoading = false;
|
||||
@state() dreamDiaryActionLoading = false;
|
||||
@state() dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null = null;
|
||||
@state() dreamDiaryActionArchivePath: string | null = null;
|
||||
@state() dreamDiaryError: string | null = null;
|
||||
@state() dreamDiaryPath: string | null = null;
|
||||
@state() dreamDiaryContent: string | null = null;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
backfillDreamDiary,
|
||||
copyDreamingArchivePath,
|
||||
dedupeDreamDiary,
|
||||
loadDreamDiary,
|
||||
loadDreamingStatus,
|
||||
loadWikiImportInsights,
|
||||
loadWikiMemoryPalace,
|
||||
repairDreamingArtifacts,
|
||||
resetGroundedShortTerm,
|
||||
resetDreamDiary,
|
||||
resolveConfiguredDreaming,
|
||||
@@ -27,6 +30,8 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
|
||||
dreamingModeSaving: false,
|
||||
dreamDiaryLoading: false,
|
||||
dreamDiaryActionLoading: false,
|
||||
dreamDiaryActionMessage: null,
|
||||
dreamDiaryActionArchivePath: null,
|
||||
dreamDiaryError: null,
|
||||
dreamDiaryPath: null,
|
||||
dreamDiaryContent: null,
|
||||
@@ -683,4 +688,108 @@ describe("dreaming controller", () => {
|
||||
expect(state.dreamDiaryContent).toBe("keep existing diary");
|
||||
expect(state.dreamDiaryActionLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("repairs dreaming artifacts and reloads only dreaming status", async () => {
|
||||
const { state, request } = createState();
|
||||
state.dreamDiaryContent = "keep existing diary";
|
||||
const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true);
|
||||
request.mockImplementation(async (method: string) => {
|
||||
if (method === "doctor.memory.repairDreamingArtifacts") {
|
||||
return {
|
||||
action: "repairDreamingArtifacts",
|
||||
changed: true,
|
||||
archiveDir: "/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
|
||||
archivedSessionCorpus: true,
|
||||
archivedSessionIngestion: true,
|
||||
};
|
||||
}
|
||||
if (method === "doctor.memory.status") {
|
||||
return { dreaming: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const ok = await repairDreamingArtifacts(state);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.repairDreamingArtifacts", {});
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
|
||||
expect(request).not.toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
|
||||
expect(state.dreamDiaryContent).toBe("keep existing diary");
|
||||
expect(state.dreamDiaryActionMessage).toEqual({
|
||||
kind: "success",
|
||||
text: "Dream cache repair complete: archived session corpus, archived ingestion state. Archive: /tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
|
||||
});
|
||||
expect(state.dreamDiaryActionArchivePath).toBe(
|
||||
"/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
|
||||
);
|
||||
expect(state.dreamDiaryActionLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("dedupes dream diary entries and reloads diary plus status", async () => {
|
||||
const { state, request } = createState();
|
||||
const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true);
|
||||
request.mockImplementation(async (method: string) => {
|
||||
if (method === "doctor.memory.dedupeDreamDiary") {
|
||||
return {
|
||||
action: "dedupeDreamDiary",
|
||||
removedEntries: 2,
|
||||
keptEntries: 5,
|
||||
};
|
||||
}
|
||||
if (method === "doctor.memory.dreamDiary") {
|
||||
return { found: true, path: "DREAMS.md", content: "deduped diary" };
|
||||
}
|
||||
if (method === "doctor.memory.status") {
|
||||
return { dreaming: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const ok = await dedupeDreamDiary(state);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.dedupeDreamDiary", {});
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
|
||||
expect(state.dreamDiaryContent).toBe("deduped diary");
|
||||
expect(state.dreamDiaryActionMessage).toEqual({
|
||||
kind: "success",
|
||||
text: "Removed 2 duplicate dream entries and kept 5.",
|
||||
});
|
||||
expect(state.dreamDiaryActionArchivePath).toBeNull();
|
||||
expect(state.dreamDiaryActionLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("copies the dreaming repair archive path", async () => {
|
||||
const { state } = createState();
|
||||
state.dreamDiaryActionArchivePath =
|
||||
"/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z";
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } } as unknown as Navigator);
|
||||
|
||||
const ok = await copyDreamingArchivePath(state);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
|
||||
);
|
||||
expect(state.dreamDiaryActionMessage).toEqual({
|
||||
kind: "success",
|
||||
text: "Archive path copied.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not run repair when confirmation is cancelled", async () => {
|
||||
const { state, request } = createState();
|
||||
vi.spyOn(globalThis, "confirm").mockReturnValue(false);
|
||||
|
||||
const ok = await repairDreamingArtifacts(state);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.dreamDiaryActionMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,9 +168,17 @@ type DoctorMemoryDreamDiaryPayload = {
|
||||
type DoctorMemoryDreamActionPayload = {
|
||||
action?: unknown;
|
||||
removedEntries?: unknown;
|
||||
dedupedEntries?: unknown;
|
||||
keptEntries?: unknown;
|
||||
written?: unknown;
|
||||
replaced?: unknown;
|
||||
removedShortTermEntries?: unknown;
|
||||
changed?: unknown;
|
||||
archiveDir?: unknown;
|
||||
archivedSessionCorpus?: unknown;
|
||||
archivedSessionIngestion?: unknown;
|
||||
archivedDreamsDiary?: unknown;
|
||||
warnings?: unknown;
|
||||
};
|
||||
|
||||
type WikiImportInsightsPayload = {
|
||||
@@ -199,6 +207,8 @@ export type DreamingState = {
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryActionLoading: boolean;
|
||||
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
|
||||
dreamDiaryActionArchivePath: string | null;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
@@ -211,6 +221,64 @@ export type DreamingState = {
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
function confirmDreamingAction(message: string): boolean {
|
||||
if (typeof globalThis.confirm !== "function") {
|
||||
return true;
|
||||
}
|
||||
return globalThis.confirm(message);
|
||||
}
|
||||
|
||||
function buildDreamDiaryActionSuccessMessage(
|
||||
method:
|
||||
| "doctor.memory.backfillDreamDiary"
|
||||
| "doctor.memory.resetDreamDiary"
|
||||
| "doctor.memory.resetGroundedShortTerm"
|
||||
| "doctor.memory.repairDreamingArtifacts"
|
||||
| "doctor.memory.dedupeDreamDiary",
|
||||
payload: DoctorMemoryDreamActionPayload | undefined,
|
||||
): string {
|
||||
switch (method) {
|
||||
case "doctor.memory.dedupeDreamDiary": {
|
||||
const removed =
|
||||
typeof payload?.dedupedEntries === "number"
|
||||
? payload.dedupedEntries
|
||||
: typeof payload?.removedEntries === "number"
|
||||
? payload.removedEntries
|
||||
: 0;
|
||||
const kept = typeof payload?.keptEntries === "number" ? payload.keptEntries : undefined;
|
||||
return kept !== undefined
|
||||
? `Removed ${removed} duplicate dream ${removed === 1 ? "entry" : "entries"} and kept ${kept}.`
|
||||
: `Removed ${removed} duplicate dream ${removed === 1 ? "entry" : "entries"}.`;
|
||||
}
|
||||
case "doctor.memory.repairDreamingArtifacts": {
|
||||
const actions: string[] = [];
|
||||
const archiveDir = normalizeTrimmedString(payload?.archiveDir);
|
||||
if (payload?.archivedSessionCorpus === true) {
|
||||
actions.push("archived session corpus");
|
||||
}
|
||||
if (payload?.archivedSessionIngestion === true) {
|
||||
actions.push("archived ingestion state");
|
||||
}
|
||||
if (payload?.archivedDreamsDiary === true) {
|
||||
actions.push("archived dream diary");
|
||||
}
|
||||
if (actions.length === 0) {
|
||||
return "Dream cache repair finished with no changes.";
|
||||
}
|
||||
return archiveDir
|
||||
? `Dream cache repair complete: ${actions.join(", ")}. Archive: ${archiveDir}`
|
||||
: `Dream cache repair complete: ${actions.join(", ")}.`;
|
||||
}
|
||||
case "doctor.memory.backfillDreamDiary":
|
||||
return `Backfilled ${typeof payload?.written === "number" ? payload.written : 0} dream diary entries.`;
|
||||
case "doctor.memory.resetDreamDiary":
|
||||
return `Removed ${typeof payload?.removedEntries === "number" ? payload.removedEntries : 0} backfilled dream diary entries.`;
|
||||
case "doctor.memory.resetGroundedShortTerm":
|
||||
return `Cleared ${typeof payload?.removedShortTermEntries === "number" ? payload.removedShortTermEntries : 0} replayed short-term entries.`;
|
||||
}
|
||||
return "Dream diary action complete.";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -708,7 +776,9 @@ async function runDreamDiaryAction(
|
||||
method:
|
||||
| "doctor.memory.backfillDreamDiary"
|
||||
| "doctor.memory.resetDreamDiary"
|
||||
| "doctor.memory.resetGroundedShortTerm",
|
||||
| "doctor.memory.resetGroundedShortTerm"
|
||||
| "doctor.memory.repairDreamingArtifacts"
|
||||
| "doctor.memory.dedupeDreamDiary",
|
||||
options?: {
|
||||
reloadDiary?: boolean;
|
||||
},
|
||||
@@ -716,20 +786,48 @@ async function runDreamDiaryAction(
|
||||
if (!state.client || !state.connected || state.dreamDiaryActionLoading) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
method === "doctor.memory.repairDreamingArtifacts" &&
|
||||
!confirmDreamingAction(
|
||||
"Repair Dream Cache? This archives derived dream cache files and rebuilds them from clean inputs. Your dream diary stays untouched.",
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
method === "doctor.memory.dedupeDreamDiary" &&
|
||||
!confirmDreamingAction(
|
||||
"Dedupe Dream Diary? This rewrites DREAMS.md and removes only exact duplicate diary entries.",
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
state.dreamDiaryActionLoading = true;
|
||||
state.dreamingStatusError = null;
|
||||
state.dreamDiaryError = null;
|
||||
state.dreamDiaryActionMessage = null;
|
||||
state.dreamDiaryActionArchivePath = null;
|
||||
try {
|
||||
await state.client.request<DoctorMemoryDreamActionPayload>(method, {});
|
||||
const payload = await state.client.request<DoctorMemoryDreamActionPayload>(method, {});
|
||||
if (options?.reloadDiary !== false) {
|
||||
await loadDreamDiary(state);
|
||||
}
|
||||
await loadDreamingStatus(state);
|
||||
state.dreamDiaryActionArchivePath =
|
||||
method === "doctor.memory.repairDreamingArtifacts"
|
||||
? (normalizeTrimmedString(payload?.archiveDir) ?? null)
|
||||
: null;
|
||||
state.dreamDiaryActionMessage = {
|
||||
kind: "success",
|
||||
text: buildDreamDiaryActionSuccessMessage(method, payload),
|
||||
};
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
state.dreamingStatusError = message;
|
||||
state.lastError = message;
|
||||
state.dreamDiaryActionArchivePath = null;
|
||||
state.dreamDiaryActionMessage = { kind: "error", text: message };
|
||||
return false;
|
||||
} finally {
|
||||
state.dreamDiaryActionLoading = false;
|
||||
@@ -750,6 +848,44 @@ export async function resetGroundedShortTerm(state: DreamingState): Promise<bool
|
||||
});
|
||||
}
|
||||
|
||||
export async function repairDreamingArtifacts(state: DreamingState): Promise<boolean> {
|
||||
return runDreamDiaryAction(state, "doctor.memory.repairDreamingArtifacts", {
|
||||
reloadDiary: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyDreamingArchivePath(state: DreamingState): Promise<boolean> {
|
||||
const path = state.dreamDiaryActionArchivePath;
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
if (!globalThis.navigator?.clipboard?.writeText) {
|
||||
state.dreamDiaryActionMessage = {
|
||||
kind: "error",
|
||||
text: "Could not copy archive path.",
|
||||
};
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await globalThis.navigator.clipboard.writeText(path);
|
||||
state.dreamDiaryActionMessage = {
|
||||
kind: "success",
|
||||
text: "Archive path copied.",
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
state.dreamDiaryActionMessage = {
|
||||
kind: "error",
|
||||
text: "Could not copy archive path.",
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dedupeDreamDiary(state: DreamingState): Promise<boolean> {
|
||||
return runDreamDiaryAction(state, "doctor.memory.dedupeDreamDiary");
|
||||
}
|
||||
|
||||
async function writeDreamingPatch(
|
||||
state: DreamingState,
|
||||
patch: Record<string, unknown>,
|
||||
|
||||
@@ -63,10 +63,13 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
|
||||
modeSaving: false,
|
||||
dreamDiaryLoading: false,
|
||||
dreamDiaryActionLoading: false,
|
||||
dreamDiaryActionMessage: null,
|
||||
dreamDiaryActionArchivePath: null,
|
||||
dreamDiaryError: null,
|
||||
dreamDiaryPath: "DREAMS.md",
|
||||
dreamDiaryContent:
|
||||
"# Dream Diary\n\n<!-- openclaw:dreaming:diary:start -->\n\n---\n\n*April 5, 2026, 3:00 AM*\n\nThe repository whispered of forgotten endpoints tonight.\n\n<!-- openclaw:dreaming:diary:end -->",
|
||||
memoryWikiEnabled: true,
|
||||
wikiImportInsightsLoading: false,
|
||||
wikiImportInsightsError: null,
|
||||
wikiImportInsights: {
|
||||
@@ -176,10 +179,14 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
|
||||
onRefreshDiary: () => {},
|
||||
onRefreshImports: () => {},
|
||||
onRefreshMemoryPalace: () => {},
|
||||
onOpenConfig: () => {},
|
||||
onOpenWikiPage: async () => null,
|
||||
onBackfillDiary: () => {},
|
||||
onCopyDreamingArchivePath: () => {},
|
||||
onDedupeDreamDiary: () => {},
|
||||
onResetDiary: () => {},
|
||||
onResetGroundedShortTerm: () => {},
|
||||
onRepairDreamingArtifacts: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -387,6 +394,27 @@ describe("dreaming view", () => {
|
||||
setDreamSubTab("scene");
|
||||
});
|
||||
|
||||
it("shows a memory-wiki enablement CTA when wiki subtabs are selected but the plugin is disabled", () => {
|
||||
setDreamSubTab("diary");
|
||||
setDreamDiarySubTab("palace");
|
||||
const onOpenConfig = vi.fn();
|
||||
const container = renderInto(
|
||||
buildProps({
|
||||
memoryWikiEnabled: false,
|
||||
onOpenConfig,
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain("Memory Wiki is not enabled");
|
||||
expect(container.textContent).toContain("plugins.entries.memory-wiki.enabled = true");
|
||||
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(".dreams-diary__empty-actions .btn")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onOpenConfig).toHaveBeenCalledTimes(1);
|
||||
setDreamDiarySubTab("dreams");
|
||||
setDreamSubTab("scene");
|
||||
});
|
||||
|
||||
it("renders dream diary with parsed entry on diary tab", () => {
|
||||
setDreamSubTab("diary");
|
||||
setDreamDiarySubTab("dreams");
|
||||
|
||||
@@ -113,9 +113,12 @@ export type DreamingProps = {
|
||||
modeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryActionLoading: boolean;
|
||||
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
|
||||
dreamDiaryActionArchivePath: string | null;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
memoryWikiEnabled: boolean;
|
||||
wikiImportInsightsLoading: boolean;
|
||||
wikiImportInsightsError: string | null;
|
||||
wikiImportInsights: WikiImportInsights | null;
|
||||
@@ -126,6 +129,7 @@ export type DreamingProps = {
|
||||
onRefreshDiary: () => void;
|
||||
onRefreshImports: () => void;
|
||||
onRefreshMemoryPalace: () => void;
|
||||
onOpenConfig: () => void;
|
||||
onOpenWikiPage: (lookup: string) => Promise<{
|
||||
title: string;
|
||||
path: string;
|
||||
@@ -135,8 +139,11 @@ export type DreamingProps = {
|
||||
updatedAt?: string;
|
||||
} | null>;
|
||||
onBackfillDiary: () => void;
|
||||
onCopyDreamingArchivePath: () => void;
|
||||
onDedupeDreamDiary: () => void;
|
||||
onResetDiary: () => void;
|
||||
onResetGroundedShortTerm: () => void;
|
||||
onRepairDreamingArtifacts: () => void;
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
@@ -763,6 +770,20 @@ function renderAdvancedSection(props: DreamingProps) {
|
||||
<div class="dreams-advanced__summary">${summary}</div>
|
||||
</div>
|
||||
<div class="dreams-advanced__actions">
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
|
||||
@click=${() => props.onDedupeDreamDiary()}
|
||||
>
|
||||
${t("dreaming.scene.dedupeDiary")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
|
||||
@click=${() => props.onRepairDreamingArtifacts()}
|
||||
>
|
||||
${t("dreaming.scene.repairCache")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
|
||||
@@ -788,6 +809,31 @@ function renderAdvancedSection(props: DreamingProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${props.dreamDiaryActionMessage
|
||||
? html`
|
||||
<div
|
||||
class="callout ${props.dreamDiaryActionMessage.kind === "success"
|
||||
? "success"
|
||||
: "danger"}"
|
||||
role="status"
|
||||
>
|
||||
<div class="row wrap items-center gap-2">
|
||||
<span>${props.dreamDiaryActionMessage.text}</span>
|
||||
${props.dreamDiaryActionArchivePath
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.dreamDiaryActionLoading}
|
||||
@click=${() => props.onCopyDreamingArchivePath()}
|
||||
>
|
||||
Copy archive path
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="dreams-advanced__sections">
|
||||
${renderAdvancedEntryList({
|
||||
@@ -1294,13 +1340,15 @@ function renderDreamDiaryEntries(props: DreamingProps) {
|
||||
// ── Diary section renderer ────────────────────────────────────────────
|
||||
|
||||
function renderDiarySection(props: DreamingProps) {
|
||||
const wikiTabSelected = _diarySubTab === "insights" || _diarySubTab === "palace";
|
||||
const memoryWikiUnavailable = wikiTabSelected && !props.memoryWikiEnabled;
|
||||
const diaryError =
|
||||
_diarySubTab === "dreams"
|
||||
? props.dreamDiaryError
|
||||
: _diarySubTab === "insights"
|
||||
? props.wikiImportInsightsError
|
||||
: props.wikiMemoryPalaceError;
|
||||
if (diaryError) {
|
||||
if (diaryError && !memoryWikiUnavailable) {
|
||||
return html`
|
||||
<section class="dreams-diary">
|
||||
<div class="dreams-diary__error">${diaryError}</div>
|
||||
@@ -1356,15 +1404,19 @@ function renderDiarySection(props: DreamingProps) {
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving ||
|
||||
(_diarySubTab === "dreams"
|
||||
? props.dreamDiaryLoading
|
||||
: _diarySubTab === "insights"
|
||||
? props.wikiImportInsightsLoading
|
||||
: props.wikiMemoryPalaceLoading)}
|
||||
?disabled=${memoryWikiUnavailable
|
||||
? false
|
||||
: props.modeSaving ||
|
||||
(_diarySubTab === "dreams"
|
||||
? props.dreamDiaryLoading
|
||||
: _diarySubTab === "insights"
|
||||
? props.wikiImportInsightsLoading
|
||||
: props.wikiMemoryPalaceLoading)}
|
||||
@click=${() => {
|
||||
_diaryPage = 0;
|
||||
if (_diarySubTab === "dreams") {
|
||||
if (memoryWikiUnavailable) {
|
||||
props.onOpenConfig();
|
||||
} else if (_diarySubTab === "dreams") {
|
||||
props.onRefreshDiary();
|
||||
} else if (_diarySubTab === "insights") {
|
||||
props.onRefreshImports();
|
||||
@@ -1373,27 +1425,48 @@ function renderDiarySection(props: DreamingProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
${_diarySubTab === "dreams"
|
||||
? props.dreamDiaryLoading
|
||||
? t("dreaming.diary.reloading")
|
||||
: t("dreaming.diary.reload")
|
||||
: _diarySubTab === "insights"
|
||||
? props.wikiImportInsightsLoading
|
||||
? "Reloading…"
|
||||
: "Reload"
|
||||
: props.wikiMemoryPalaceLoading
|
||||
? "Reloading…"
|
||||
: "Reload"}
|
||||
${memoryWikiUnavailable
|
||||
? "How to enable"
|
||||
: _diarySubTab === "dreams"
|
||||
? props.dreamDiaryLoading
|
||||
? t("dreaming.diary.reloading")
|
||||
: t("dreaming.diary.reload")
|
||||
: _diarySubTab === "insights"
|
||||
? props.wikiImportInsightsLoading
|
||||
? "Reloading…"
|
||||
: "Reload"
|
||||
: props.wikiMemoryPalaceLoading
|
||||
? "Reloading…"
|
||||
: "Reload"}
|
||||
</button>
|
||||
</div>
|
||||
${renderDiarySubtabExplainer()}
|
||||
</div>
|
||||
|
||||
${_diarySubTab === "dreams"
|
||||
? renderDreamDiaryEntries(props)
|
||||
: _diarySubTab === "insights"
|
||||
? renderDiaryImportsSection(props)
|
||||
: renderMemoryPalaceSection(props)}
|
||||
${memoryWikiUnavailable
|
||||
? html`
|
||||
<div class="dreams-diary__empty">
|
||||
<div class="dreams-diary__empty-text">Memory Wiki is not enabled</div>
|
||||
<div class="dreams-diary__empty-hint">
|
||||
Imported Insights and Memory Palace are provided by the bundled
|
||||
<code>memory-wiki</code> plugin.
|
||||
</div>
|
||||
<div class="dreams-diary__empty-hint">
|
||||
Enable <code>plugins.entries.memory-wiki.enabled = true</code>, then reload this
|
||||
tab.
|
||||
</div>
|
||||
<div class="dreams-diary__empty-actions">
|
||||
<button class="btn btn--subtle btn--sm" @click=${() => props.onOpenConfig()}>
|
||||
Open Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: _diarySubTab === "dreams"
|
||||
? renderDreamDiaryEntries(props)
|
||||
: _diarySubTab === "insights"
|
||||
? renderDiaryImportsSection(props)
|
||||
: renderMemoryPalaceSection(props)}
|
||||
${renderWikiPreviewOverlay(props)}
|
||||
</section>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user