[codex] Dreaming: surface memory wiki imports and palace (#64505)

Merged via squash.

Prepared head SHA: 12d5e37222
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-11 07:04:08 +02:00
committed by GitHub
parent 6492cc7428
commit 64693d2e96
23 changed files with 4002 additions and 80 deletions

View File

@@ -690,7 +690,7 @@
position: sticky;
top: 0;
z-index: 3;
width: min(100%, 680px);
width: min(100%, 920px);
max-width: 100%;
min-width: 0;
padding-bottom: 14px;
@@ -709,6 +709,7 @@
gap: 16px;
margin-bottom: 14px;
flex-shrink: 0;
flex-wrap: wrap;
}
.dreams-diary__title {
@@ -721,13 +722,45 @@
flex: 1;
}
.dreams-diary__subtabs {
display: inline-flex;
gap: 6px;
padding: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 82%, transparent);
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
}
.dreams-diary__subtab {
border: 0;
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
}
.dreams-diary__subtab--active {
color: var(--text);
background: color-mix(in oklab, var(--accent-subtle) 88%, transparent);
}
.dreams-diary__explainer {
width: min(100%, 920px);
margin: 0 0 16px;
font-size: 12px;
line-height: 1.6;
color: var(--muted);
}
/* ---- Diary entry ---- */
.dreams-diary__entry {
position: relative;
z-index: 1;
max-width: 680px;
width: min(100%, 680px);
max-width: 920px;
width: min(100%, 920px);
min-width: 0;
padding: 0 0 0 16px;
flex-shrink: 0;
@@ -789,7 +822,7 @@
display: flex;
gap: 6px;
margin: 0;
width: min(100%, 680px);
width: min(100%, 920px);
max-width: 100%;
min-width: 0;
overflow-x: auto;
@@ -838,6 +871,198 @@
overflow-x: clip;
}
.dreams-diary__insights {
display: grid;
gap: 12px;
margin-top: 18px;
}
.dreams-diary__insight-card {
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
background: color-mix(in oklab, var(--panel) 86%, transparent);
border-radius: 14px;
padding: 14px;
}
.dreams-diary__insight-card--clickable {
cursor: pointer;
transition:
border-color 140ms ease,
background 140ms ease,
transform 140ms ease;
}
.dreams-diary__insight-card--clickable:hover {
border-color: color-mix(in oklab, var(--accent) 24%, var(--border));
background: color-mix(in oklab, var(--panel) 92%, transparent);
transform: translateY(-1px);
}
.dreams-diary__insight-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.dreams-diary__insight-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.dreams-diary__insight-meta {
font-size: 11px;
color: var(--muted);
margin-bottom: 10px;
}
.dreams-diary__insight-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 3px 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
}
.dreams-diary__insight-badge--high {
color: color-mix(in oklab, var(--danger) 82%, white);
background: color-mix(in oklab, var(--danger) 10%, transparent);
}
.dreams-diary__insight-badge--low,
.dreams-diary__insight-badge--medium,
.dreams-diary__insight-badge--unknown {
color: var(--muted);
}
.dreams-diary__insight-badge--palace {
color: var(--accent);
background: color-mix(in oklab, var(--accent-subtle) 72%, transparent);
border-color: color-mix(in oklab, var(--accent) 24%, transparent);
}
.dreams-diary__insight-line {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.6;
color: var(--text);
overflow-wrap: anywhere;
}
.dreams-diary__insight-list {
margin-top: 10px;
}
.dreams-diary__insight-list strong {
display: block;
margin-bottom: 6px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.dreams-diary__insight-signals {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 10px;
}
.dreams-diary__insight-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.dreams-diary__insight-signal {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--text);
background: color-mix(in oklab, var(--accent-subtle) 84%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 24%, transparent);
}
.dreams-diary__preview-backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: stretch;
justify-content: center;
padding: 32px;
background: color-mix(in oklab, var(--bg) 72%, black 28%);
backdrop-filter: blur(10px);
}
.dreams-diary__preview-panel {
display: flex;
flex-direction: column;
width: min(1120px, 100%);
min-height: 0;
border-radius: 18px;
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
background: color-mix(in oklab, var(--panel) 96%, transparent);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.dreams-diary__preview-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid color-mix(in oklab, var(--border) 62%, transparent);
}
.dreams-diary__preview-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.dreams-diary__preview-meta {
margin-top: 6px;
font-size: 11px;
line-height: 1.5;
color: var(--muted);
overflow-wrap: anywhere;
}
.dreams-diary__preview-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 20px;
}
.dreams-diary__preview-hint {
margin-bottom: 12px;
font-size: 12px;
line-height: 1.5;
color: var(--muted);
}
.dreams-diary__preview-pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: var(--mono);
font-size: 12px;
line-height: 1.65;
color: var(--text);
}
.dreams-diary__para {
margin: 0 0 12px;
font-size: 13px;
@@ -926,4 +1151,12 @@
.dreams-diary {
padding: 20px 16px 48px;
}
.dreams-diary__preview-backdrop {
padding: 12px;
}
.dreams-diary__preview-header {
flex-direction: column;
}
}

View File

@@ -69,6 +69,8 @@ import {
backfillDreamDiary,
loadDreamDiary,
loadDreamingStatus,
loadWikiImportInsights,
loadWikiMemoryPalace,
resetGroundedShortTerm,
resetDreamDiary,
resolveConfiguredDreaming,
@@ -426,7 +428,54 @@ export function renderApp(state: AppViewState) {
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading;
const refreshDreaming = () => {
void Promise.all([loadDreamingStatus(state), loadDreamDiary(state)]);
void Promise.all([
loadDreamingStatus(state),
loadDreamDiary(state),
loadWikiImportInsights(state),
loadWikiMemoryPalace(state),
]);
};
const openWikiPage = async (lookup: string) => {
if (!state.client || !state.connected) {
return null;
}
const payload = (await state.client.request("wiki.get", {
lookup,
fromLine: 1,
lineCount: 5000,
})) as {
title?: unknown;
path?: unknown;
content?: unknown;
updatedAt?: unknown;
totalLines?: unknown;
truncated?: unknown;
} | null;
const title =
typeof payload?.title === "string" && payload.title.trim() ? payload.title.trim() : lookup;
const path =
typeof payload?.path === "string" && payload.path.trim() ? payload.path.trim() : lookup;
const content =
typeof payload?.content === "string" && payload.content.length > 0
? payload.content
: "No wiki content available.";
const updatedAt =
typeof payload?.updatedAt === "string" && payload.updatedAt.trim()
? payload.updatedAt.trim()
: undefined;
const totalLines =
typeof payload?.totalLines === "number" && Number.isFinite(payload.totalLines)
? Math.max(0, Math.floor(payload.totalLines))
: undefined;
const truncated = payload?.truncated === true;
return {
title,
path,
content,
...(totalLines !== undefined ? { totalLines } : {}),
...(truncated ? { truncated } : {}),
...(updatedAt ? { updatedAt } : {}),
};
};
const applyDreamingEnabled = (enabled: boolean) => {
if (state.dreamingModeSaving || dreamingOn === enabled) {
@@ -1933,8 +1982,17 @@ export function renderApp(state: AppViewState) {
dreamDiaryError: state.dreamDiaryError,
dreamDiaryPath: state.dreamDiaryPath,
dreamDiaryContent: state.dreamDiaryContent,
wikiImportInsightsLoading: state.wikiImportInsightsLoading,
wikiImportInsightsError: state.wikiImportInsightsError,
wikiImportInsights: state.wikiImportInsights,
wikiMemoryPalaceLoading: state.wikiMemoryPalaceLoading,
wikiMemoryPalaceError: state.wikiMemoryPalaceError,
wikiMemoryPalace: state.wikiMemoryPalace,
onRefresh: refreshDreaming,
onRefreshDiary: () => loadDreamDiary(state),
onRefreshImports: () => loadWikiImportInsights(state),
onRefreshMemoryPalace: () => loadWikiMemoryPalace(state),
onOpenWikiPage: (lookup: string) => openWikiPage(lookup),
onBackfillDiary: () => backfillDreamDiary(state),
onResetDiary: () => resetDreamDiary(state),
onResetGroundedShortTerm: () => resetGroundedShortTerm(state),

View File

@@ -162,6 +162,12 @@ const createHost = (tab: Tab): SettingsHost => ({
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,
wikiImportInsightsLoading: false,
wikiImportInsightsError: null,
wikiImportInsights: null,
wikiMemoryPalaceLoading: false,
wikiMemoryPalaceError: null,
wikiMemoryPalace: null,
});
describe("setTabFromRoute", () => {

View File

@@ -25,7 +25,13 @@ import {
} from "./controllers/cron.ts";
import { loadDebug, type DebugState } from "./controllers/debug.ts";
import { loadDevices, type DevicesState } from "./controllers/devices.ts";
import { loadDreamDiary, loadDreamingStatus, type DreamingState } from "./controllers/dreaming.ts";
import {
loadDreamDiary,
loadDreamingStatus,
loadWikiImportInsights,
loadWikiMemoryPalace,
type DreamingState,
} from "./controllers/dreaming.ts";
import { loadExecApprovals, type ExecApprovalsState } from "./controllers/exec-approvals.ts";
import { loadLogs, type LogsState } from "./controllers/logs.ts";
import { loadNodes, type NodesState } from "./controllers/nodes.ts";
@@ -330,7 +336,12 @@ export async function refreshActiveTab(host: SettingsHost) {
return;
case "dreams":
await loadConfig(app);
await Promise.all([loadDreamingStatus(app), loadDreamDiary(app)]);
await Promise.all([
loadDreamingStatus(app),
loadDreamDiary(app),
loadWikiImportInsights(app),
loadWikiMemoryPalace(app),
]);
return;
case "chat":
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);

View File

@@ -133,6 +133,12 @@ export type AppViewState = {
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
wikiImportInsightsLoading: boolean;
wikiImportInsightsError: string | null;
wikiImportInsights: import("./controllers/dreaming.js").WikiImportInsights | null;
wikiMemoryPalaceLoading: boolean;
wikiMemoryPalaceError: string | null;
wikiMemoryPalace: import("./controllers/dreaming.js").WikiMemoryPalace | null;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;

View File

@@ -61,7 +61,11 @@ 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 {
DreamingStatus,
WikiImportInsights,
WikiMemoryPalace,
} from "./controllers/dreaming.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
import type {
@@ -232,6 +236,12 @@ export class OpenClawApp extends LitElement {
@state() dreamDiaryError: string | null = null;
@state() dreamDiaryPath: string | null = null;
@state() dreamDiaryContent: string | null = null;
@state() wikiImportInsightsLoading = false;
@state() wikiImportInsightsError: string | null = null;
@state() wikiImportInsights: WikiImportInsights | null = null;
@state() wikiMemoryPalaceLoading = false;
@state() wikiMemoryPalaceError: string | null = null;
@state() wikiMemoryPalace: WikiMemoryPalace | null = null;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";

View File

@@ -3,6 +3,8 @@ import {
backfillDreamDiary,
loadDreamDiary,
loadDreamingStatus,
loadWikiImportInsights,
loadWikiMemoryPalace,
resetGroundedShortTerm,
resetDreamDiary,
resolveConfiguredDreaming,
@@ -28,6 +30,12 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,
wikiImportInsightsLoading: false,
wikiImportInsightsError: null,
wikiImportInsights: null,
wikiMemoryPalaceLoading: false,
wikiMemoryPalaceError: null,
wikiMemoryPalace: null,
lastError: null,
};
return { state, request };
@@ -212,6 +220,124 @@ describe("dreaming controller", () => {
expect(state.dreamingStatusError).toBeNull();
});
it("loads and normalizes wiki import insights", async () => {
const { state, request } = createState();
request.mockResolvedValue({
sourceType: "chatgpt",
totalItems: 2,
totalClusters: 1,
clusters: [
{
key: "topic/travel",
label: "Travel",
itemCount: 2,
highRiskCount: 1,
withheldCount: 1,
preferenceSignalCount: 1,
items: [
{
pagePath: "sources/chatgpt-2026-04-10-alpha.md",
title: "BA flight receipts process",
riskLevel: "low",
riskReasons: [],
labels: ["topic/travel"],
topicKey: "topic/travel",
topicLabel: "Travel",
digestStatus: "available",
activeBranchMessages: 4,
userMessageCount: 2,
assistantMessageCount: 2,
firstUserLine: "how do i get receipts?",
lastUserLine: "that option does not exist",
assistantOpener: "Use the BA request-a-receipt flow first.",
summary: "Use the BA request-a-receipt flow first.",
candidateSignals: ["prefers airline receipts"],
correctionSignals: [],
preferenceSignals: ["prefers airline receipts"],
},
],
},
],
});
await loadWikiImportInsights(state);
expect(request).toHaveBeenCalledWith("wiki.importInsights", {});
expect(state.wikiImportInsights).toEqual(
expect.objectContaining({
totalItems: 2,
totalClusters: 1,
clusters: [
expect.objectContaining({
key: "topic/travel",
itemCount: 2,
withheldCount: 1,
}),
],
}),
);
expect(state.wikiImportInsightsError).toBeNull();
expect(state.wikiImportInsightsLoading).toBe(false);
});
it("loads and normalizes the wiki memory palace", async () => {
const { state, request } = createState();
request.mockResolvedValue({
totalItems: 2,
totalClaims: 3,
totalQuestions: 1,
totalContradictions: 1,
clusters: [
{
key: "synthesis",
label: "Syntheses",
itemCount: 1,
claimCount: 2,
questionCount: 1,
contradictionCount: 0,
items: [
{
pagePath: "syntheses/travel-system.md",
title: "Travel system",
kind: "synthesis",
claimCount: 2,
questionCount: 1,
contradictionCount: 0,
claims: ["prefers direct receipts"],
questions: ["should this become a playbook?"],
contradictions: [],
snippet: "Recurring travel admin friction.",
},
],
},
],
});
await loadWikiMemoryPalace(state);
expect(request).toHaveBeenCalledWith("wiki.palace", {});
expect(state.wikiMemoryPalace).toEqual(
expect.objectContaining({
totalItems: 2,
totalClaims: 3,
clusters: [
expect.objectContaining({
key: "synthesis",
label: "Syntheses",
items: [
expect.objectContaining({
title: "Travel system",
claims: ["prefers direct receipts"],
}),
],
}),
],
}),
);
expect(state.wikiMemoryPalaceError).toBeNull();
expect(state.wikiMemoryPalaceLoading).toBe(false);
});
it("patches config to update global dreaming enablement", async () => {
const { state, request } = createState();
state.configSnapshot = {

View File

@@ -79,6 +79,82 @@ export type DreamingStatus = {
};
};
export type WikiImportInsightItem = {
pagePath: string;
title: string;
riskLevel: "low" | "medium" | "high" | "unknown";
riskReasons: string[];
labels: string[];
topicKey: string;
topicLabel: string;
digestStatus: "available" | "withheld";
activeBranchMessages: number;
userMessageCount: number;
assistantMessageCount: number;
firstUserLine?: string;
lastUserLine?: string;
assistantOpener?: string;
summary: string;
candidateSignals: string[];
correctionSignals: string[];
preferenceSignals: string[];
createdAt?: string;
updatedAt?: string;
};
export type WikiImportInsightCluster = {
key: string;
label: string;
itemCount: number;
highRiskCount: number;
withheldCount: number;
preferenceSignalCount: number;
updatedAt?: string;
items: WikiImportInsightItem[];
};
export type WikiImportInsights = {
sourceType: "chatgpt";
totalItems: number;
totalClusters: number;
clusters: WikiImportInsightCluster[];
};
export type WikiMemoryPalaceItem = {
pagePath: string;
title: string;
kind: "entity" | "concept" | "source" | "synthesis" | "report";
id?: string;
updatedAt?: string;
sourceType?: string;
claimCount: number;
questionCount: number;
contradictionCount: number;
claims: string[];
questions: string[];
contradictions: string[];
snippet?: string;
};
export type WikiMemoryPalaceCluster = {
key: WikiMemoryPalaceItem["kind"];
label: string;
itemCount: number;
claimCount: number;
questionCount: number;
contradictionCount: number;
updatedAt?: string;
items: WikiMemoryPalaceItem[];
};
export type WikiMemoryPalace = {
totalItems: number;
totalClaims: number;
totalQuestions: number;
totalContradictions: number;
clusters: WikiMemoryPalaceCluster[];
};
type DoctorMemoryStatusPayload = {
dreaming?: unknown;
};
@@ -97,6 +173,21 @@ type DoctorMemoryDreamActionPayload = {
removedShortTermEntries?: unknown;
};
type WikiImportInsightsPayload = {
sourceType?: unknown;
totalItems?: unknown;
totalClusters?: unknown;
clusters?: unknown;
};
type WikiMemoryPalacePayload = {
totalItems?: unknown;
totalClaims?: unknown;
totalQuestions?: unknown;
totalContradictions?: unknown;
clusters?: unknown;
};
export type DreamingState = {
client: GatewayBrowserClient | null;
connected: boolean;
@@ -111,6 +202,12 @@ export type DreamingState = {
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
wikiImportInsightsLoading: boolean;
wikiImportInsightsError: string | null;
wikiImportInsights: WikiImportInsights | null;
wikiMemoryPalaceLoading: boolean;
wikiMemoryPalaceError: string | null;
wikiMemoryPalace: WikiMemoryPalace | null;
lastError: string | null;
};
@@ -232,6 +329,230 @@ function normalizeDreamingEntries(raw: unknown): DreamingEntry[] {
.map((entry) => normalizeDreamingEntry(entry))
.filter((entry): entry is DreamingEntry => entry !== null);
}
function normalizeStringArray(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
}
function normalizeWikiImportInsightItem(raw: unknown): WikiImportInsightItem | null {
const record = asRecord(raw);
const pagePath = normalizeTrimmedString(record?.pagePath);
const title = normalizeTrimmedString(record?.title);
const riskLevel = normalizeTrimmedString(record?.riskLevel);
const topicKey = normalizeTrimmedString(record?.topicKey);
const topicLabel = normalizeTrimmedString(record?.topicLabel);
const digestStatus = normalizeTrimmedString(record?.digestStatus);
const summary = normalizeTrimmedString(record?.summary);
if (
!pagePath ||
!title ||
!topicKey ||
!topicLabel ||
!summary ||
(riskLevel !== "low" &&
riskLevel !== "medium" &&
riskLevel !== "high" &&
riskLevel !== "unknown") ||
(digestStatus !== "available" && digestStatus !== "withheld")
) {
return null;
}
return {
pagePath,
title,
riskLevel,
riskReasons: normalizeStringArray(record?.riskReasons),
labels: normalizeStringArray(record?.labels),
topicKey,
topicLabel,
digestStatus,
activeBranchMessages: normalizeFiniteInt(record?.activeBranchMessages, 0),
userMessageCount: normalizeFiniteInt(record?.userMessageCount, 0),
assistantMessageCount: normalizeFiniteInt(record?.assistantMessageCount, 0),
...(normalizeTrimmedString(record?.firstUserLine)
? { firstUserLine: normalizeTrimmedString(record?.firstUserLine) }
: {}),
...(normalizeTrimmedString(record?.lastUserLine)
? { lastUserLine: normalizeTrimmedString(record?.lastUserLine) }
: {}),
...(normalizeTrimmedString(record?.assistantOpener)
? { assistantOpener: normalizeTrimmedString(record?.assistantOpener) }
: {}),
summary,
candidateSignals: normalizeStringArray(record?.candidateSignals),
correctionSignals: normalizeStringArray(record?.correctionSignals),
preferenceSignals: normalizeStringArray(record?.preferenceSignals),
...(normalizeTrimmedString(record?.createdAt)
? { createdAt: normalizeTrimmedString(record?.createdAt) }
: {}),
...(normalizeTrimmedString(record?.updatedAt)
? { updatedAt: normalizeTrimmedString(record?.updatedAt) }
: {}),
};
}
function normalizeWikiImportInsightCluster(raw: unknown): WikiImportInsightCluster | null {
const record = asRecord(raw);
const key = normalizeTrimmedString(record?.key);
const label = normalizeTrimmedString(record?.label);
if (!key || !label) {
return null;
}
const items = Array.isArray(record?.items)
? record.items
.map((entry) => normalizeWikiImportInsightItem(entry))
.filter((entry): entry is WikiImportInsightItem => entry !== null)
: [];
return {
key,
label,
itemCount: normalizeFiniteInt(record?.itemCount, items.length),
highRiskCount: normalizeFiniteInt(
record?.highRiskCount,
items.filter((entry) => entry.riskLevel === "high").length,
),
withheldCount: normalizeFiniteInt(
record?.withheldCount,
items.filter((entry) => entry.digestStatus === "withheld").length,
),
preferenceSignalCount: normalizeFiniteInt(
record?.preferenceSignalCount,
items.reduce((sum, entry) => sum + entry.preferenceSignals.length, 0),
),
...(normalizeTrimmedString(record?.updatedAt)
? { updatedAt: normalizeTrimmedString(record?.updatedAt) }
: {}),
items,
};
}
function normalizeWikiImportInsights(raw: unknown): WikiImportInsights {
const record = asRecord(raw);
const clusters = Array.isArray(record?.clusters)
? record.clusters
.map((entry) => normalizeWikiImportInsightCluster(entry))
.filter((entry): entry is WikiImportInsightCluster => entry !== null)
: [];
return {
sourceType: record?.sourceType === "chatgpt" ? "chatgpt" : "chatgpt",
totalItems: normalizeFiniteInt(
record?.totalItems,
clusters.reduce((sum, cluster) => sum + cluster.itemCount, 0),
),
totalClusters: normalizeFiniteInt(record?.totalClusters, clusters.length),
clusters,
};
}
function normalizeWikiPageKind(value: unknown): WikiMemoryPalaceItem["kind"] | undefined {
return value === "entity" ||
value === "concept" ||
value === "source" ||
value === "synthesis" ||
value === "report"
? value
: undefined;
}
function normalizeWikiMemoryPalaceItem(raw: unknown): WikiMemoryPalaceItem | null {
const record = asRecord(raw);
const pagePath = normalizeTrimmedString(record?.pagePath);
const title = normalizeTrimmedString(record?.title);
const kind = normalizeWikiPageKind(record?.kind);
if (!pagePath || !title || !kind) {
return null;
}
return {
pagePath,
title,
kind,
...(normalizeTrimmedString(record?.id) ? { id: normalizeTrimmedString(record?.id) } : {}),
...(normalizeTrimmedString(record?.updatedAt)
? { updatedAt: normalizeTrimmedString(record?.updatedAt) }
: {}),
...(normalizeTrimmedString(record?.sourceType)
? { sourceType: normalizeTrimmedString(record?.sourceType) }
: {}),
claimCount: normalizeFiniteInt(record?.claimCount, 0),
questionCount: normalizeFiniteInt(record?.questionCount, 0),
contradictionCount: normalizeFiniteInt(record?.contradictionCount, 0),
claims: normalizeStringArray(record?.claims),
questions: normalizeStringArray(record?.questions),
contradictions: normalizeStringArray(record?.contradictions),
...(normalizeTrimmedString(record?.snippet)
? { snippet: normalizeTrimmedString(record?.snippet) }
: {}),
};
}
function normalizeWikiMemoryPalaceCluster(raw: unknown): WikiMemoryPalaceCluster | null {
const record = asRecord(raw);
const key = normalizeWikiPageKind(record?.key);
const label = normalizeTrimmedString(record?.label);
if (!key || !label) {
return null;
}
const items = Array.isArray(record?.items)
? record.items
.map((entry) => normalizeWikiMemoryPalaceItem(entry))
.filter((entry): entry is WikiMemoryPalaceItem => entry !== null)
: [];
return {
key,
label,
itemCount: normalizeFiniteInt(record?.itemCount, items.length),
claimCount: normalizeFiniteInt(
record?.claimCount,
items.reduce((sum, item) => sum + item.claimCount, 0),
),
questionCount: normalizeFiniteInt(
record?.questionCount,
items.reduce((sum, item) => sum + item.questionCount, 0),
),
contradictionCount: normalizeFiniteInt(
record?.contradictionCount,
items.reduce((sum, item) => sum + item.contradictionCount, 0),
),
...(normalizeTrimmedString(record?.updatedAt)
? { updatedAt: normalizeTrimmedString(record?.updatedAt) }
: {}),
items,
};
}
function normalizeWikiMemoryPalace(raw: unknown): WikiMemoryPalace {
const record = asRecord(raw);
const clusters = Array.isArray(record?.clusters)
? record.clusters
.map((entry) => normalizeWikiMemoryPalaceCluster(entry))
.filter((entry): entry is WikiMemoryPalaceCluster => entry !== null)
: [];
return {
totalItems: normalizeFiniteInt(
record?.totalItems,
clusters.reduce((sum, cluster) => sum + cluster.itemCount, 0),
),
totalClaims: normalizeFiniteInt(
record?.totalClaims,
clusters.reduce((sum, cluster) => sum + cluster.claimCount, 0),
),
totalQuestions: normalizeFiniteInt(
record?.totalQuestions,
clusters.reduce((sum, cluster) => sum + cluster.questionCount, 0),
),
totalContradictions: normalizeFiniteInt(
record?.totalContradictions,
clusters.reduce((sum, cluster) => sum + cluster.contradictionCount, 0),
),
clusters,
};
}
function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
const record = asRecord(raw);
if (!record) {
@@ -347,6 +668,41 @@ export async function loadDreamDiary(state: DreamingState): Promise<void> {
}
}
export async function loadWikiImportInsights(state: DreamingState): Promise<void> {
if (!state.client || !state.connected || state.wikiImportInsightsLoading) {
return;
}
state.wikiImportInsightsLoading = true;
state.wikiImportInsightsError = null;
try {
const payload = await state.client.request<WikiImportInsightsPayload>(
"wiki.importInsights",
{},
);
state.wikiImportInsights = normalizeWikiImportInsights(payload);
} catch (err) {
state.wikiImportInsightsError = String(err);
} finally {
state.wikiImportInsightsLoading = false;
}
}
export async function loadWikiMemoryPalace(state: DreamingState): Promise<void> {
if (!state.client || !state.connected || state.wikiMemoryPalaceLoading) {
return;
}
state.wikiMemoryPalaceLoading = true;
state.wikiMemoryPalaceError = null;
try {
const payload = await state.client.request<WikiMemoryPalacePayload>("wiki.palace", {});
state.wikiMemoryPalace = normalizeWikiMemoryPalace(payload);
} catch (err) {
state.wikiMemoryPalaceError = String(err);
} finally {
state.wikiMemoryPalaceLoading = false;
}
}
async function runDreamDiaryAction(
state: DreamingState,
method:

View File

@@ -1,10 +1,11 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
renderDreaming,
setDreamAdvancedWaitingSort,
setDreamDiarySubTab,
setDreamSubTab,
type DreamingProps,
} from "./dreaming.ts";
@@ -66,8 +67,116 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
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 -->",
wikiImportInsightsLoading: false,
wikiImportInsightsError: null,
wikiImportInsights: {
sourceType: "chatgpt",
totalItems: 2,
totalClusters: 2,
clusters: [
{
key: "topic/travel",
label: "Travel",
itemCount: 1,
highRiskCount: 0,
withheldCount: 0,
preferenceSignalCount: 1,
items: [
{
pagePath: "sources/chatgpt-2026-04-10-alpha.md",
title: "BA flight receipts process",
riskLevel: "low",
riskReasons: [],
labels: ["domain/personal", "area/travel", "topic/travel"],
topicKey: "topic/travel",
topicLabel: "Travel",
digestStatus: "available",
activeBranchMessages: 4,
userMessageCount: 2,
assistantMessageCount: 2,
firstUserLine: "how do i get receipts?",
lastUserLine: "that option does not exist",
assistantOpener: "Use the BA request-a-receipt flow first.",
summary: "Use the BA request-a-receipt flow first.",
candidateSignals: ["prefers direct airline receipts"],
correctionSignals: [],
preferenceSignals: ["prefers direct airline receipts"],
updatedAt: "2026-04-10T10:00:00.000Z",
},
],
},
{
key: "topic/health",
label: "Health",
itemCount: 1,
highRiskCount: 1,
withheldCount: 1,
preferenceSignalCount: 0,
items: [
{
pagePath: "sources/chatgpt-2026-04-10-health.md",
title: "Migraine Medication Advice",
riskLevel: "high",
riskReasons: ["health"],
labels: ["domain/personal", "area/health", "topic/health"],
topicKey: "topic/health",
topicLabel: "Health",
digestStatus: "withheld",
activeBranchMessages: 2,
userMessageCount: 1,
assistantMessageCount: 1,
summary:
"Sensitive health chat withheld from durable-memory extraction because it touches health.",
candidateSignals: [],
correctionSignals: [],
preferenceSignals: [],
updatedAt: "2026-04-11T10:00:00.000Z",
},
],
},
],
},
wikiMemoryPalaceLoading: false,
wikiMemoryPalaceError: null,
wikiMemoryPalace: {
totalItems: 2,
totalClaims: 3,
totalQuestions: 1,
totalContradictions: 1,
clusters: [
{
key: "synthesis",
label: "Syntheses",
itemCount: 1,
claimCount: 2,
questionCount: 1,
contradictionCount: 1,
items: [
{
pagePath: "syntheses/travel-system.md",
title: "Travel system",
kind: "synthesis",
claimCount: 2,
questionCount: 1,
contradictionCount: 1,
claims: [
"Mariano prefers direct receipts from airlines when possible.",
"Travel admin friction keeps showing up across chats.",
],
questions: ["Should flight receipts be standardized into one process?"],
contradictions: ["Old BA receipts guidance may now be stale."],
snippet: "Recurring travel admin friction across imported chats.",
updatedAt: "2026-04-10T10:00:00.000Z",
},
],
},
],
},
onRefresh: () => {},
onRefreshDiary: () => {},
onRefreshImports: () => {},
onRefreshMemoryPalace: () => {},
onOpenWikiPage: async () => null,
onBackfillDiary: () => {},
onResetDiary: () => {},
onResetGroundedShortTerm: () => {},
@@ -189,8 +298,98 @@ describe("dreaming view", () => {
expect(tabs[2]?.textContent).toContain("Advanced");
});
it("renders imported memory topics inside the diary tab", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("insights");
const container = renderInto(buildProps());
expect(container.querySelectorAll(".dreams-diary__subtab").length).toBe(3);
expect(container.querySelector(".dreams-diary__date")?.textContent).toContain("Travel");
expect(container.querySelector(".dreams-diary__insight-card")?.textContent).toContain(
"BA flight receipts process",
);
expect(container.querySelector(".dreams-diary__insight-card")?.textContent).toContain(
"Use the BA request-a-receipt flow first.",
);
expect(container.querySelector(".dreams-diary__explainer")?.textContent).toContain(
"imported insights clustered from external history",
);
setDreamDiarySubTab("dreams");
setDreamSubTab("scene");
});
it("opens the full imported source page from diary cards", async () => {
setDreamSubTab("diary");
setDreamDiarySubTab("insights");
const onOpenWikiPage = vi.fn().mockResolvedValue({
title: "BA flight receipts process",
path: "sources/chatgpt-2026-04-10-alpha.md",
content: "# ChatGPT Export: BA flight receipts process",
});
const container = renderInto(buildProps({ onOpenWikiPage }));
container
.querySelectorAll<HTMLButtonElement>(".dreams-diary__insight-actions .btn")[1]
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
expect(onOpenWikiPage).toHaveBeenCalledWith("sources/chatgpt-2026-04-10-alpha.md");
setDreamDiarySubTab("dreams");
setDreamSubTab("scene");
});
it("shows a truncation hint when the wiki preview only contains the first chunk", async () => {
setDreamSubTab("diary");
setDreamDiarySubTab("insights");
const container = document.createElement("div");
let props: DreamingProps;
const onOpenWikiPage = vi.fn().mockResolvedValue({
title: "BA flight receipts process",
path: "sources/chatgpt-2026-04-10-alpha.md",
content: "# ChatGPT Export: BA flight receipts process",
totalLines: 6001,
truncated: true,
});
const rerender = () => render(renderDreaming(props), container);
props = buildProps({
onOpenWikiPage,
onRequestUpdate: rerender,
});
rerender();
container
.querySelectorAll<HTMLButtonElement>(".dreams-diary__insight-actions .btn")[1]
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
expect(container.querySelector(".dreams-diary__preview-hint")?.textContent).toContain(
"6001 total lines",
);
container
.querySelector<HTMLButtonElement>(".dreams-diary__preview-header .btn")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
setDreamDiarySubTab("dreams");
setDreamSubTab("scene");
});
it("renders the memory palace inside the diary tab", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("palace");
const container = renderInto(buildProps());
expect(container.querySelector(".dreams-diary__date")?.textContent).toContain("Syntheses");
expect(container.querySelector(".dreams-diary__insight-card")?.textContent).toContain(
"Travel system",
);
expect(container.querySelector(".dreams-diary__insight-card")?.textContent).toContain("Claims");
expect(container.querySelector(".dreams-diary__explainer")?.textContent).toContain(
"compiled memory wiki surface",
);
setDreamDiarySubTab("dreams");
setDreamSubTab("scene");
});
it("renders dream diary with parsed entry on diary tab", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");
const container = renderInto(buildProps());
const title = container.querySelector(".dreams-diary__title");
expect(title?.textContent).toContain("Dream Diary");
@@ -206,6 +405,7 @@ describe("dreaming view", () => {
it("flattens structured backfill diary entries into plain prose", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");
const container = renderInto(
buildProps({
dreamDiaryContent: [
@@ -248,6 +448,7 @@ describe("dreaming view", () => {
it("renders diary day chips without the old density map", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");
const container = renderInto(
buildProps({
dreamDiaryContent: [
@@ -288,6 +489,7 @@ describe("dreaming view", () => {
it("shows empty diary state when no diary content exists", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");
const container = renderInto(buildProps({ dreamDiaryContent: null }));
expect(container.querySelector(".dreams-diary__empty")).not.toBeNull();
expect(container.querySelector(".dreams-diary__empty-text")?.textContent).toContain(
@@ -298,6 +500,7 @@ describe("dreaming view", () => {
it("shows diary error message when diary load fails", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");
const container = renderInto(buildProps({ dreamDiaryError: "read failed" }));
expect(container.querySelector(".dreams-diary__error")?.textContent).toContain("read failed");
setDreamSubTab("scene");
@@ -305,6 +508,7 @@ describe("dreaming view", () => {
it("does not render the old page navigation chrome", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");
const container = renderInto(buildProps());
expect(container.querySelector(".dreams-diary__page")).toBeNull();
expect(container.querySelector(".dreams-diary__nav-btn")).toBeNull();

View File

@@ -1,6 +1,10 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import type { DreamingEntry } from "../controllers/dreaming.ts";
import type {
DreamingEntry,
WikiImportInsights,
WikiMemoryPalace,
} from "../controllers/dreaming.ts";
// ── Diary entry parser ─────────────────────────────────────────────────
@@ -112,8 +116,24 @@ export type DreamingProps = {
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
wikiImportInsightsLoading: boolean;
wikiImportInsightsError: string | null;
wikiImportInsights: WikiImportInsights | null;
wikiMemoryPalaceLoading: boolean;
wikiMemoryPalaceError: string | null;
wikiMemoryPalace: WikiMemoryPalace | null;
onRefresh: () => void;
onRefreshDiary: () => void;
onRefreshImports: () => void;
onRefreshMemoryPalace: () => void;
onOpenWikiPage: (lookup: string) => Promise<{
title: string;
path: string;
content: string;
totalLines?: number;
truncated?: boolean;
updatedAt?: string;
} | null>;
onBackfillDiary: () => void;
onResetDiary: () => void;
onResetGroundedShortTerm: () => void;
@@ -154,8 +174,21 @@ const DREAM_SWAP_MS = 6_000;
type DreamSubTab = "scene" | "diary" | "advanced";
let _subTab: DreamSubTab = "scene";
type DreamDiarySubTab = "dreams" | "insights" | "palace";
let _diarySubTab: DreamDiarySubTab = "dreams";
type AdvancedWaitingSort = "recent" | "signals";
let _advancedWaitingSort: AdvancedWaitingSort = "recent";
const _expandedInsightCards = new Set<string>();
const _expandedPalaceCards = new Set<string>();
let _wikiPreviewOpen = false;
let _wikiPreviewLoading = false;
let _wikiPreviewTitle = "";
let _wikiPreviewPath = "";
let _wikiPreviewUpdatedAt: string | null = null;
let _wikiPreviewContent = "";
let _wikiPreviewTotalLines: number | null = null;
let _wikiPreviewTruncated = false;
let _wikiPreviewError: string | null = null;
export function setDreamSubTab(tab: DreamSubTab): void {
_subTab = tab;
@@ -165,6 +198,10 @@ export function setDreamAdvancedWaitingSort(sort: AdvancedWaitingSort): void {
_advancedWaitingSort = sort;
}
export function setDreamDiarySubTab(tab: DreamDiarySubTab): void {
_diarySubTab = tab;
}
// ── Diary pagination state ─────────────────────────────────────────────
let _diaryPage = 0;
@@ -430,6 +467,174 @@ function formatCompactDateTime(value: string): string {
});
}
function basename(value: string): string {
const normalized = value.replace(/\\/g, "/");
return normalized.split("/").filter(Boolean).at(-1) ?? value;
}
function formatKindLabel(kind: "entity" | "concept" | "source" | "synthesis" | "report"): string {
switch (kind) {
case "entity":
return "entity";
case "concept":
return "concept";
case "source":
return "source";
case "synthesis":
return "synthesis";
case "report":
return "report";
}
}
function formatImportBadge(item: {
digestStatus: "available" | "withheld";
riskLevel: "low" | "medium" | "high" | "unknown";
}): string {
if (item.digestStatus === "withheld") {
return "needs review";
}
switch (item.riskLevel) {
case "low":
return "low risk";
case "medium":
return "medium risk";
case "high":
return "high risk";
case "unknown":
return "unknown risk";
}
}
function toggleExpandedCard(bucket: Set<string>, key: string, requestUpdate?: () => void): void {
if (bucket.has(key)) {
bucket.delete(key);
} else {
bucket.add(key);
}
requestUpdate?.();
}
async function openWikiPreview(lookup: string, props: DreamingProps): Promise<void> {
_wikiPreviewOpen = true;
_wikiPreviewLoading = true;
_wikiPreviewTitle = basename(lookup);
_wikiPreviewPath = lookup;
_wikiPreviewUpdatedAt = null;
_wikiPreviewContent = "";
_wikiPreviewTotalLines = null;
_wikiPreviewTruncated = false;
_wikiPreviewError = null;
props.onRequestUpdate?.();
try {
const preview = await props.onOpenWikiPage(lookup);
if (!preview) {
_wikiPreviewError = `No wiki page found for ${lookup}.`;
return;
}
_wikiPreviewTitle = preview.title;
_wikiPreviewPath = preview.path;
_wikiPreviewUpdatedAt = preview.updatedAt ?? null;
_wikiPreviewContent = preview.content;
_wikiPreviewTotalLines = typeof preview.totalLines === "number" ? preview.totalLines : null;
_wikiPreviewTruncated = preview.truncated === true;
} catch (error) {
_wikiPreviewError = String(error);
} finally {
_wikiPreviewLoading = false;
props.onRequestUpdate?.();
}
}
function closeWikiPreview(requestUpdate?: () => void): void {
_wikiPreviewOpen = false;
_wikiPreviewLoading = false;
_wikiPreviewTitle = "";
_wikiPreviewPath = "";
_wikiPreviewUpdatedAt = null;
_wikiPreviewContent = "";
_wikiPreviewTotalLines = null;
_wikiPreviewTruncated = false;
_wikiPreviewError = null;
requestUpdate?.();
}
function renderWikiPreviewOverlay(props: DreamingProps) {
if (!_wikiPreviewOpen) {
return nothing;
}
return html`
<div
class="dreams-diary__preview-backdrop"
@click=${() => closeWikiPreview(props.onRequestUpdate)}
>
<div class="dreams-diary__preview-panel" @click=${(event: Event) => event.stopPropagation()}>
<div class="dreams-diary__preview-header">
<div>
<div class="dreams-diary__preview-title">${_wikiPreviewTitle || "Wiki page"}</div>
<div class="dreams-diary__preview-meta">
${_wikiPreviewPath} ${_wikiPreviewUpdatedAt ? ` · ${_wikiPreviewUpdatedAt}` : ""}
</div>
</div>
<button
class="btn btn--subtle btn--sm"
@click=${() => closeWikiPreview(props.onRequestUpdate)}
>
Close
</button>
</div>
<div class="dreams-diary__preview-body">
${_wikiPreviewLoading
? html`<div class="dreams-diary__empty-text">Loading wiki page…</div>`
: _wikiPreviewError
? html`<div class="dreams-diary__error">${_wikiPreviewError}</div>`
: html`
${_wikiPreviewTruncated
? html`
<div class="dreams-diary__preview-hint">
Showing the first chunk of this
page${_wikiPreviewTotalLines !== null
? ` (${_wikiPreviewTotalLines} total lines)`
: ""}.
</div>
`
: nothing}
<pre class="dreams-diary__preview-pre">${_wikiPreviewContent}</pre>
`}
</div>
</div>
</div>
`;
}
function renderDiarySubtabExplainer() {
switch (_diarySubTab) {
case "dreams":
return html`
<p class="dreams-diary__explainer">
This is the raw dream diary the system writes while replaying and consolidating memory;
use it to inspect what the memory system is noticing, and where it still looks noisy or
thin.
</p>
`;
case "insights":
return html`
<p class="dreams-diary__explainer">
These are imported insights clustered from external history; use them to review what
imports surfaced before any of it graduates into durable memory.
</p>
`;
case "palace":
return html`
<p class="dreams-diary__explainer">
This is the compiled memory wiki surface the system can search and reason over; use it to
inspect actual memory pages, claims, open questions, and contradictions rather than raw
imported source chats.
</p>
`;
}
}
function parseSortableTimestamp(value?: string): number {
if (!value) {
return Number.NEGATIVE_INFINITY;
@@ -674,42 +879,363 @@ function renderAdvancedSection(props: DreamingProps) {
`;
}
// ── Diary section renderer ────────────────────────────────────────────
function renderDiaryImportsSection(props: DreamingProps) {
const importInsights = props.wikiImportInsights;
const clusters = importInsights?.clusters ?? [];
function renderDiarySection(props: DreamingProps) {
if (props.dreamDiaryError) {
if (props.wikiImportInsightsLoading && clusters.length === 0) {
return html`
<section class="dreams-diary">
<div class="dreams-diary__error">${props.dreamDiaryError}</div>
</section>
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">Loading imported insights…</div>
</div>
`;
}
if (clusters.length === 0) {
return html`
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">No imported insights yet</div>
<div class="dreams-diary__empty-hint">
Run a ChatGPT import with apply to surface clustered imported insights here.
</div>
</div>
`;
}
_diaryEntryCount = clusters.length;
const clusterIndex = Math.max(0, Math.min(_diaryPage, clusters.length - 1));
const cluster = clusters[clusterIndex];
return html`
<div class="dreams-diary__daychips">
${clusters.map(
(entry, index) => html`
<button
class="dreams-diary__day-chip ${index === clusterIndex
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(index);
props.onRequestUpdate?.();
}}
>
${entry.label}
</button>
`,
)}
</div>
<article class="dreams-diary__entry" key="imports-${cluster.key}">
<div class="dreams-diary__accent"></div>
<div class="dreams-diary__date">
${cluster.label} · ${cluster.itemCount} chats
${cluster.highRiskCount > 0 ? html`· ${cluster.highRiskCount} sensitive` : nothing}
${cluster.preferenceSignalCount > 0
? html`· ${cluster.preferenceSignalCount} signals`
: nothing}
</div>
<div class="dreams-diary__prose">
<p class="dreams-diary__para">
Imported chats clustered around ${cluster.label.toLowerCase()}.
${cluster.withheldCount > 0
? ` ${cluster.withheldCount} digest${cluster.withheldCount === 1 ? " was" : "s were"} withheld pending review.`
: ""}
</p>
</div>
<div class="dreams-diary__insights">
${cluster.items.map((item) => {
const expanded = _expandedInsightCards.has(item.pagePath);
return html`
<article
class="dreams-diary__insight-card dreams-diary__insight-card--clickable"
data-import-page=${item.pagePath}
@click=${() =>
toggleExpandedCard(_expandedInsightCards, item.pagePath, props.onRequestUpdate)}
>
<div class="dreams-diary__insight-topline">
<div class="dreams-diary__insight-title">${item.title}</div>
<span
class="dreams-diary__insight-badge dreams-diary__insight-badge--${item.riskLevel}"
>
${formatImportBadge(item)}
</span>
</div>
<div class="dreams-diary__insight-meta">
${item.updatedAt ? formatCompactDateTime(item.updatedAt) : basename(item.pagePath)}
${item.activeBranchMessages > 0 ? ` · ${item.activeBranchMessages} messages` : ""}
</div>
<p class="dreams-diary__insight-line">${item.summary}</p>
${item.candidateSignals.length > 0
? html`
<div class="dreams-diary__insight-list">
<strong>Potentially useful signals</strong>
${item.candidateSignals.map(
(signal) => html`<p class="dreams-diary__insight-line">• ${signal}</p>`,
)}
</div>
`
: nothing}
${item.correctionSignals.length > 0
? html`
<div class="dreams-diary__insight-list">
<strong>Corrections or revisions</strong>
${item.correctionSignals.map(
(signal) => html`<p class="dreams-diary__insight-line">• ${signal}</p>`,
)}
</div>
`
: nothing}
${expanded
? html`
<div class="dreams-diary__insight-list">
<strong>Import details</strong>
${item.firstUserLine
? html`
<p class="dreams-diary__insight-line">
<strong>Started with:</strong> ${item.firstUserLine}
</p>
`
: nothing}
${item.lastUserLine && item.lastUserLine !== item.firstUserLine
? html`
<p class="dreams-diary__insight-line">
<strong>Ended on:</strong> ${item.lastUserLine}
</p>
`
: nothing}
<p class="dreams-diary__insight-line">
<strong>Messages:</strong> ${item.userMessageCount} user ·
${item.assistantMessageCount} assistant
</p>
${item.riskReasons.length > 0
? html`
<p class="dreams-diary__insight-line">
<strong>Risk reasons:</strong> ${item.riskReasons.join(", ")}
</p>
`
: nothing}
${item.labels.length > 0
? html`
<p class="dreams-diary__insight-line">
<strong>Labels:</strong> ${item.labels.join(", ")}
</p>
`
: nothing}
</div>
`
: nothing}
${item.preferenceSignals.length > 0
? html`
<div class="dreams-diary__insight-signals">
${item.preferenceSignals.map(
(signal) =>
html`<span class="dreams-diary__insight-signal">${signal}</span>`,
)}
</div>
`
: nothing}
<div class="dreams-diary__insight-actions">
<button
class="btn btn--subtle btn--sm"
@click=${(event: Event) => {
event.stopPropagation();
toggleExpandedCard(_expandedInsightCards, item.pagePath, props.onRequestUpdate);
}}
>
${expanded ? "Hide details" : "Details"}
</button>
<button
class="btn btn--subtle btn--sm"
@click=${(event: Event) => {
event.stopPropagation();
void openWikiPreview(item.pagePath, props);
}}
>
Open source page
</button>
</div>
</article>
`;
})}
</div>
</article>
`;
}
function renderMemoryPalaceSection(props: DreamingProps) {
const palace = props.wikiMemoryPalace;
const clusters = palace?.clusters ?? [];
if (props.wikiMemoryPalaceLoading && clusters.length === 0) {
return html`
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">Loading memory palace…</div>
</div>
`;
}
if (clusters.length === 0) {
return html`
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">Memory palace is not populated yet</div>
<div class="dreams-diary__empty-hint">
Right now the wiki mostly has raw source imports and operational reports. This tab becomes
useful once syntheses, entities, or concepts start getting written.
</div>
</div>
`;
}
_diaryEntryCount = clusters.length;
const clusterIndex = Math.max(0, Math.min(_diaryPage, clusters.length - 1));
const cluster = clusters[clusterIndex];
return html`
<div class="dreams-diary__daychips">
${clusters.map(
(entry, index) => html`
<button
class="dreams-diary__day-chip ${index === clusterIndex
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(index);
props.onRequestUpdate?.();
}}
>
${entry.label}
</button>
`,
)}
</div>
<article class="dreams-diary__entry" key="palace-${cluster.key}">
<div class="dreams-diary__accent"></div>
<div class="dreams-diary__date">
${cluster.label} · ${cluster.itemCount} pages
${cluster.claimCount > 0 ? html`· ${cluster.claimCount} claims` : nothing}
${cluster.questionCount > 0 ? html`· ${cluster.questionCount} questions` : nothing}
${cluster.contradictionCount > 0
? html`· ${cluster.contradictionCount} contradictions`
: nothing}
</div>
<div class="dreams-diary__prose">
<p class="dreams-diary__para">
Compiled wiki pages currently grouped under ${cluster.label.toLowerCase()}.
${cluster.updatedAt ? ` Latest update ${formatCompactDateTime(cluster.updatedAt)}.` : ""}
</p>
</div>
<div class="dreams-diary__insights">
${cluster.items.map((item) => {
const expanded = _expandedPalaceCards.has(item.pagePath);
return html`
<article
class="dreams-diary__insight-card dreams-diary__insight-card--clickable"
data-palace-page=${item.pagePath}
@click=${() =>
toggleExpandedCard(_expandedPalaceCards, item.pagePath, props.onRequestUpdate)}
>
<div class="dreams-diary__insight-topline">
<div class="dreams-diary__insight-title">${item.title}</div>
<span class="dreams-diary__insight-badge dreams-diary__insight-badge--palace">
${formatKindLabel(item.kind)}
</span>
</div>
<div class="dreams-diary__insight-meta">
${item.updatedAt ? formatCompactDateTime(item.updatedAt) : basename(item.pagePath)}
· ${item.pagePath}
</div>
${item.snippet
? html`<p class="dreams-diary__insight-line">${item.snippet}</p>`
: nothing}
${item.claims.length > 0
? html`
<div class="dreams-diary__insight-list">
<strong>Claims</strong>
${item.claims.map(
(claim) => html`<p class="dreams-diary__insight-line">• ${claim}</p>`,
)}
</div>
`
: nothing}
${item.questions.length > 0
? html`
<div class="dreams-diary__insight-list">
<strong>Open questions</strong>
${item.questions.map(
(question) => html`<p class="dreams-diary__insight-line">• ${question}</p>`,
)}
</div>
`
: nothing}
${item.contradictions.length > 0
? html`
<div class="dreams-diary__insight-list">
<strong>Contradictions</strong>
${item.contradictions.map(
(entry) => html`<p class="dreams-diary__insight-line">• ${entry}</p>`,
)}
</div>
`
: nothing}
${expanded
? html`
<div class="dreams-diary__insight-list">
<strong>Page details</strong>
<p class="dreams-diary__insight-line">
<strong>Wiki page:</strong> ${item.pagePath}
</p>
${item.id
? html`
<p class="dreams-diary__insight-line">
<strong>Id:</strong> ${item.id}
</p>
`
: nothing}
</div>
`
: nothing}
<div class="dreams-diary__insight-actions">
<button
class="btn btn--subtle btn--sm"
@click=${(event: Event) => {
event.stopPropagation();
toggleExpandedCard(_expandedPalaceCards, item.pagePath, props.onRequestUpdate);
}}
>
${expanded ? "Hide details" : "Details"}
</button>
<button
class="btn btn--subtle btn--sm"
@click=${(event: Event) => {
event.stopPropagation();
void openWikiPreview(item.pagePath, props);
}}
>
Open wiki page
</button>
</div>
</article>
`;
})}
</div>
</article>
`;
}
function renderDreamDiaryEntries(props: DreamingProps) {
if (typeof props.dreamDiaryContent !== "string") {
return html`
<section class="dreams-diary">
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-moon">
<svg viewBox="0 0 32 32" fill="none" width="32" height="32">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
stroke-width="0.5"
opacity="0.2"
/>
<path
d="M20 8a10 10 0 0 1 0 16 10 10 0 1 0 0-16z"
fill="currentColor"
opacity="0.08"
/>
</svg>
</div>
<div class="dreams-diary__empty-text">${t("dreaming.diary.noDreamsYet")}</div>
<div class="dreams-diary__empty-hint">${t("dreaming.diary.noDreamsHint")}</div>
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-moon">
<svg viewBox="0 0 32 32" fill="none" width="32" height="32">
<circle cx="16" cy="16" r="14" stroke="currentColor" stroke-width="0.5" opacity="0.2" />
<path d="M20 8a10 10 0 0 1 0 16 10 10 0 1 0 0-16z" fill="currentColor" opacity="0.08" />
</svg>
</div>
</section>
<div class="dreams-diary__empty-text">${t("dreaming.diary.noDreamsYet")}</div>
<div class="dreams-diary__empty-hint">${t("dreaming.diary.noDreamsHint")}</div>
</div>
`;
}
@@ -718,69 +1244,154 @@ function renderDiarySection(props: DreamingProps) {
if (entries.length === 0) {
return html`
<section class="dreams-diary">
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">${t("dreaming.diary.waitingTitle")}</div>
<div class="dreams-diary__empty-hint">${t("dreaming.diary.waitingHint")}</div>
</div>
</section>
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">${t("dreaming.diary.waitingTitle")}</div>
<div class="dreams-diary__empty-hint">${t("dreaming.diary.waitingHint")}</div>
</div>
`;
}
const reversed = buildDiaryNavigation(entries);
// Clamp page.
const page = Math.max(0, Math.min(_diaryPage, reversed.length - 1));
const entry = reversed[page];
return html`
<div class="dreams-diary__daychips">
${reversed.map(
(e) => html`
<button
class="dreams-diary__day-chip ${e.page === page
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(e.page);
props.onRequestUpdate?.();
}}
>
${formatDiaryChipLabel(e.date)}
</button>
`,
)}
</div>
<article class="dreams-diary__entry" key="${page}">
<div class="dreams-diary__accent"></div>
${entry.date ? html`<time class="dreams-diary__date">${entry.date}</time>` : nothing}
<div class="dreams-diary__prose">
${flattenDiaryBody(entry.body).map(
(para, i) =>
html`<p class="dreams-diary__para" style="animation-delay: ${0.3 + i * 0.15}s;">
${para}
</p>`,
)}
</div>
</article>
`;
}
// ── Diary section renderer ────────────────────────────────────────────
function renderDiarySection(props: DreamingProps) {
const diaryError =
_diarySubTab === "dreams"
? props.dreamDiaryError
: _diarySubTab === "insights"
? props.wikiImportInsightsError
: props.wikiMemoryPalaceError;
if (diaryError) {
return html`
<section class="dreams-diary">
<div class="dreams-diary__error">${diaryError}</div>
</section>
`;
}
return html`
<section class="dreams-diary">
<div class="dreams-diary__chrome">
<div class="dreams-diary__header">
<span class="dreams-diary__title">${t("dreaming.diary.title")}</span>
<div class="dreams-diary__subtabs">
<button
class="dreams-diary__subtab ${_diarySubTab === "dreams"
? "dreams-diary__subtab--active"
: ""}"
@click=${() => {
closeWikiPreview();
_diarySubTab = "dreams";
_diaryPage = 0;
props.onRequestUpdate?.();
}}
>
Dreams
</button>
<button
class="dreams-diary__subtab ${_diarySubTab === "insights"
? "dreams-diary__subtab--active"
: ""}"
@click=${() => {
closeWikiPreview();
_diarySubTab = "insights";
_diaryPage = 0;
props.onRequestUpdate?.();
}}
>
Imported Insights
</button>
<button
class="dreams-diary__subtab ${_diarySubTab === "palace"
? "dreams-diary__subtab--active"
: ""}"
@click=${() => {
closeWikiPreview();
_diarySubTab = "palace";
_diaryPage = 0;
props.onRequestUpdate?.();
}}
>
Memory Palace
</button>
</div>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryLoading}
?disabled=${props.modeSaving ||
(_diarySubTab === "dreams"
? props.dreamDiaryLoading
: _diarySubTab === "insights"
? props.wikiImportInsightsLoading
: props.wikiMemoryPalaceLoading)}
@click=${() => {
_diaryPage = 0;
props.onRefreshDiary();
if (_diarySubTab === "dreams") {
props.onRefreshDiary();
} else if (_diarySubTab === "insights") {
props.onRefreshImports();
} else {
props.onRefreshMemoryPalace();
}
}}
>
${props.dreamDiaryLoading ? t("dreaming.diary.reloading") : t("dreaming.diary.reload")}
${_diarySubTab === "dreams"
? props.dreamDiaryLoading
? t("dreaming.diary.reloading")
: t("dreaming.diary.reload")
: _diarySubTab === "insights"
? props.wikiImportInsightsLoading
? "Reloading…"
: "Reload"
: props.wikiMemoryPalaceLoading
? "Reloading…"
: "Reload"}
</button>
</div>
<!-- Simple day chips -->
<div class="dreams-diary__daychips">
${reversed.map(
(e) => html`
<button
class="dreams-diary__day-chip ${e.page === page
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(e.page);
props.onRequestUpdate?.();
}}
>
${formatDiaryChipLabel(e.date)}
</button>
`,
)}
</div>
${renderDiarySubtabExplainer()}
</div>
<article class="dreams-diary__entry" key="${page}">
<div class="dreams-diary__accent"></div>
${entry.date ? html`<time class="dreams-diary__date">${entry.date}</time>` : nothing}
<div class="dreams-diary__prose">
${flattenDiaryBody(entry.body).map(
(para, i) =>
html`<p class="dreams-diary__para" style="animation-delay: ${0.3 + i * 0.15}s;">
${para}
</p>`,
)}
</div>
</article>
${_diarySubTab === "dreams"
? renderDreamDiaryEntries(props)
: _diarySubTab === "insights"
? renderDiaryImportsSection(props)
: renderMemoryPalaceSection(props)}
${renderWikiPreviewOverlay(props)}
</section>
`;
}