mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 04:00:21 +00:00
[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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user