[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

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Agents: add an opt-in strict-agentic embedded Pi execution contract for GPT-5-family runs so plan-only or filler turns keep acting until they hit a real blocker. (#64241) Thanks @100yenadmin.
- Agents/OpenAI: add provider-owned OpenAI/Codex tool schema compatibility and surface embedded-run replay/liveness state for long-running runs. (#64300) Thanks @100yenadmin.
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
- Dreaming/memory-wiki: add ChatGPT import ingestion plus new `Imported Insights` and `Memory Palace` diary subtabs so Dreaming can inspect imported source chats, compiled wiki pages, and full source pages directly from the UI. (#64505)
### Fixes

View File

@@ -0,0 +1,903 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import {
parseWikiMarkdown,
renderWikiMarkdown,
WIKI_RELATED_END_MARKER,
WIKI_RELATED_START_MARKER,
} from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
const CHATGPT_PREFERENCE_SIGNAL_RE =
/\b(prefer|prefers|preference|want|wants|need|needs|avoid|avoids|hate|hates|love|loves|default to|should default to|always use|don't want|does not want|likes|dislikes)\b/i;
const HUMAN_START_MARKER = "<!-- openclaw:human:start -->";
const HUMAN_END_MARKER = "<!-- openclaw:human:end -->";
const CHATGPT_RISK_RULES: Array<{ label: string; pattern: RegExp }> = [
{
label: "relationships",
pattern:
/\b(relationship|dating|breakup|jealous|sex|intimacy|partner|apology|trust|boyfriend|girlfriend|husband|wife)\b/i,
},
{
label: "health",
pattern:
/\b(supplement|medication|diagnosis|symptom|therapy|depression|anxiety|mri|migraine|injury|pain|cortisol|sleep)\b/i,
},
{
label: "legal_tax",
pattern:
/\b(contract|tax|legal|law|lawsuit|visa|immigration|license|insurance|claim|non-residence|residency)\b/i,
},
{
label: "finance",
pattern:
/\b(investment|invest|portfolio|dividend|yield|coupon|valuation|mortgage|loan|crypto|covered call|call option|put option)\b/i,
},
{
label: "drugs",
pattern: /\b(vape|weed|cannabis|nicotine|opioid|ketamine)\b/i,
},
];
type ChatGptMessage = {
role: string;
text: string;
};
type ChatGptRiskAssessment = {
level: "low" | "medium" | "high";
reasons: string[];
};
type ChatGptConversationRecord = {
conversationId: string;
title: string;
createdAt?: string;
updatedAt?: string;
sourcePath: string;
pageId: string;
pagePath: string;
labels: string[];
risk: ChatGptRiskAssessment;
userMessageCount: number;
assistantMessageCount: number;
preferenceSignals: string[];
firstUserLine?: string;
lastUserLine?: string;
transcript: ChatGptMessage[];
};
type ChatGptImportOperation = "create" | "update" | "skip";
export type ChatGptImportAction = {
conversationId: string;
title: string;
pagePath: string;
operation: ChatGptImportOperation;
riskLevel: ChatGptRiskAssessment["level"];
labels: string[];
userMessageCount: number;
assistantMessageCount: number;
preferenceSignals: string[];
};
type ChatGptImportRunEntry = {
path: string;
snapshotPath?: string;
};
type ChatGptImportRunRecord = {
version: 1;
runId: string;
importType: "chatgpt";
exportPath: string;
sourcePath: string;
appliedAt: string;
conversationCount: number;
createdCount: number;
updatedCount: number;
skippedCount: number;
createdPaths: string[];
updatedPaths: ChatGptImportRunEntry[];
rolledBackAt?: string;
};
export type ChatGptImportResult = {
dryRun: boolean;
exportPath: string;
sourcePath: string;
conversationCount: number;
createdCount: number;
updatedCount: number;
skippedCount: number;
actions: ChatGptImportAction[];
pagePaths: string[];
runId?: string;
indexUpdatedFiles: string[];
};
export type ChatGptRollbackResult = {
runId: string;
removedCount: number;
restoredCount: number;
pagePaths: string[];
indexUpdatedFiles: string[];
alreadyRolledBack: boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function resolveConversationSourcePath(exportInputPath: string): {
exportPath: string;
conversationsPath: string;
} {
const resolved = path.resolve(exportInputPath);
const conversationsPath = resolved.endsWith(".json")
? resolved
: path.join(resolved, "conversations.json");
return {
exportPath: resolved,
conversationsPath,
};
}
async function loadConversations(exportInputPath: string): Promise<{
exportPath: string;
conversationsPath: string;
conversations: Record<string, unknown>[];
}> {
const { exportPath, conversationsPath } = resolveConversationSourcePath(exportInputPath);
const raw = await fs.readFile(conversationsPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return {
exportPath,
conversationsPath,
conversations: parsed.filter(
(entry): entry is Record<string, unknown> => asRecord(entry) !== null,
),
};
}
const record = asRecord(parsed);
if (record) {
for (const value of Object.values(record)) {
if (Array.isArray(value)) {
return {
exportPath,
conversationsPath,
conversations: value.filter(
(entry): entry is Record<string, unknown> => asRecord(entry) !== null,
),
};
}
}
}
throw new Error(`Unrecognized ChatGPT conversations export format: ${conversationsPath}`);
}
function isoFromUnix(raw: unknown): string | undefined {
if (typeof raw !== "number" && typeof raw !== "string") {
return undefined;
}
const numeric = Number(raw);
if (!Number.isFinite(numeric)) {
return undefined;
}
return new Date(numeric * 1000).toISOString();
}
function cleanMessageText(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (
(trimmed.includes("asset_pointer") ||
trimmed.includes("image_asset_pointer") ||
trimmed.includes("dalle") ||
trimmed.includes("file_service")) &&
trimmed.length > 40
) {
return "";
}
if (
trimmed.startsWith("{") &&
trimmed.length > 80 &&
(trimmed.includes(":") || trimmed.includes("content_type"))
) {
const textMatch = trimmed.match(/["']text["']\s*:\s*(["'])(.+?)\1/s);
return textMatch?.[2] ? normalizeWhitespace(textMatch[2]) : "";
}
return trimmed;
}
function extractMessageText(message: Record<string, unknown>): string {
const content = asRecord(message.content);
if (content) {
const parts = content.parts;
if (Array.isArray(parts)) {
const collected: string[] = [];
for (const part of parts) {
if (typeof part === "string") {
const cleaned = cleanMessageText(part);
if (cleaned) {
collected.push(cleaned);
}
continue;
}
const partRecord = asRecord(part);
if (partRecord && typeof partRecord.text === "string" && partRecord.text.trim()) {
collected.push(partRecord.text.trim());
}
}
return collected.join("\n").trim();
}
if (typeof content.text === "string") {
return cleanMessageText(content.text);
}
}
return typeof message.text === "string" ? cleanMessageText(message.text) : "";
}
function activeBranchMessages(conversation: Record<string, unknown>): ChatGptMessage[] {
const mapping = asRecord(conversation.mapping);
if (!mapping) {
return [];
}
let currentNode =
typeof conversation.current_node === "string" ? conversation.current_node : undefined;
const seen = new Set<string>();
const chain: ChatGptMessage[] = [];
while (currentNode && !seen.has(currentNode)) {
seen.add(currentNode);
const node = asRecord(mapping[currentNode]);
if (!node) {
break;
}
const message = asRecord(node.message);
if (message) {
const author = asRecord(message.author);
const role = typeof author?.role === "string" ? author.role : "unknown";
const text = extractMessageText(message);
if (text) {
chain.push({ role, text });
}
}
currentNode = typeof node.parent === "string" ? node.parent : undefined;
}
return chain.reverse();
}
function inferRisk(title: string, sampleText: string): ChatGptRiskAssessment {
const blob = `${title}\n${sampleText}`.toLowerCase();
const reasons = CHATGPT_RISK_RULES.filter((rule) => rule.pattern.test(blob)).map(
(rule) => rule.label,
);
if (reasons.length > 0) {
return { level: "high", reasons: [...new Set(reasons)] };
}
if (/\b(career|job|salary|interview|offer|resume|cover letter)\b/i.test(blob)) {
return { level: "medium", reasons: ["work_career"] };
}
return { level: "low", reasons: [] };
}
function inferLabels(title: string, sampleText: string): string[] {
const blob = `${title}\n${sampleText}`.toLowerCase();
const labels = new Set<string>(["domain/personal"]);
const addAreaTopic = (area: string, topics: string[]) => {
labels.add(area);
for (const topic of topics) {
labels.add(topic);
}
};
const hasTranslation =
/\b(translate|translation|traduc\w*|traducc\w*|traduç\w*|traducci[oó]n|traduccio|traducció|traduzione)\b/i.test(
blob,
);
const hasLearning =
/\b(anki|flashcards?|grammar|conjugat\w*|declension|pronunciation|vocab(?:ular(?:y|io))?|lesson|tutor|teacher|jlpt|kanji|hiragana|katakana|study|learn|practice)\b/i.test(
blob,
);
const hasLanguageName =
/\b(japanese|portuguese|catalan|castellano|espa[nñ]ol|franc[eé]s|french|italian|german|spanish)\b/i.test(
blob,
);
if (hasTranslation) {
labels.add("topic/translation");
}
if (
hasLearning ||
(hasLanguageName && /\b(learn|study|practice|lesson|tutor|grammar)\b/i.test(blob))
) {
addAreaTopic("area/language-learning", ["topic/language-learning"]);
}
if (
/\b(hike|trail|hotel|flight|trip|travel|airport|itinerary|booking|airbnb|train|stay)\b/i.test(
blob,
)
) {
labels.add("area/travel");
labels.add("topic/travel");
}
if (
/\b(recipe|cook|cooking|bread|sourdough|pizza|espresso|coffee|mousse|cast iron|meatballs?)\b/i.test(
blob,
)
) {
addAreaTopic("area/cooking", ["topic/cooking"]);
}
if (
/\b(garden|orchard|plant|soil|compost|agroforestry|permaculture|mulch|beds?|irrigation|seeds?)\b/i.test(
blob,
)
) {
addAreaTopic("area/gardening", ["topic/gardening"]);
}
if (/\b(dating|relationship|partner|jealous|breakup|trust)\b/i.test(blob)) {
addAreaTopic("area/relationships", ["topic/relationships"]);
}
if (
/\b(investment|invest|portfolio|dividend|yield|coupon|valuation|return|mortgage|loan|kraken|crypto|covered call|call option|put option|option chain|bond|stocks?)\b/i.test(
blob,
)
) {
addAreaTopic("area/finance", ["topic/finance"]);
}
if (
/\b(contract|mou|tax|impuesto|legal|law|lawsuit|visa|immigration|license|licencia|dispute|claim|insurance|non-residence|residency)\b/i.test(
blob,
)
) {
addAreaTopic("area/legal-tax", ["topic/legal-tax"]);
}
if (
/\b(supplement|medication|diagnos(?:is|e)|symptom|therapy|depress(?:ion|ed)|anxiet(?:y|ies)|mri|migraine|injur(?:y|ies)|pain|cortisol|sleep|dentist|dermatolog(?:ist|y))\b/i.test(
blob,
)
) {
addAreaTopic("area/health", ["topic/health"]);
}
if (
/\b(book (an )?appointment|rebook|open (a )?new account|driving test|exam|gestor(?:a)?|itv)\b/i.test(
blob,
)
) {
addAreaTopic("area/life-admin", ["topic/life-admin"]);
}
if (/\b(frc|robot|robotics|wpilib|limelight|chiefdelphi)\b/i.test(blob)) {
addAreaTopic("area/work", ["topic/robotics"]);
} else if (
/\b(docker|git|python|node|npm|pip|sql|postgres|api|bug|stack trace|permission denied)\b/i.test(
blob,
)
) {
addAreaTopic("area/work", ["topic/software"]);
} else if (/\b(job|interview|cover letter|resume|cv)\b/i.test(blob)) {
addAreaTopic("area/work", ["topic/career"]);
}
if (/\b(wifi|wi-fi|starlink|router|mesh|network|orbi|milesight|coverage)\b/i.test(blob)) {
addAreaTopic("area/home", ["topic/home-infrastructure"]);
}
if (
/\b(p38|range rover|porsche|bmw|bobcat|excavator|auger|trailer|chainsaw|stihl)\b/i.test(blob)
) {
addAreaTopic("area/vehicles", ["topic/vehicles"]);
}
if (![...labels].some((label) => label.startsWith("area/"))) {
labels.add("area/other");
}
return [...labels];
}
function collectPreferenceSignals(userTexts: string[]): string[] {
const signals: string[] = [];
const seen = new Set<string>();
for (const text of userTexts.slice(0, 25)) {
for (const rawLine of text.split(/\r?\n/)) {
const line = normalizeWhitespace(rawLine);
if (!line || !CHATGPT_PREFERENCE_SIGNAL_RE.test(line)) {
continue;
}
const key = line.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
signals.push(line);
if (signals.length >= 10) {
return signals;
}
}
}
return signals;
}
function buildTranscript(messages: ChatGptMessage[]): string {
if (messages.length === 0) {
return "_No active-branch transcript could be reconstructed._";
}
return messages
.flatMap((message) => [
`### ${message.role[0]?.toUpperCase() ?? "U"}${message.role.slice(1)}`,
"",
message.text,
"",
])
.join("\n")
.trim();
}
function resolveConversationPagePath(record: { conversationId: string; createdAt?: string }): {
pageId: string;
pagePath: string;
} {
const conversationSlug = record.conversationId.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
const pageId = `source.chatgpt.${conversationSlug || createHash("sha1").update(record.conversationId).digest("hex").slice(0, 12)}`;
const datePrefix = record.createdAt?.slice(0, 10) ?? "undated";
const shortId = conversationSlug.slice(0, 8) || "export";
return {
pageId,
pagePath: path
.join("sources", `chatgpt-${datePrefix}-${conversationSlug || shortId}.md`)
.replace(/\\/g, "/"),
};
}
function toConversationRecord(
conversation: Record<string, unknown>,
sourcePath: string,
): ChatGptConversationRecord | null {
const conversationId =
typeof conversation.conversation_id === "string" ? conversation.conversation_id.trim() : "";
if (!conversationId) {
return null;
}
const title =
typeof conversation.title === "string" && conversation.title.trim()
? conversation.title.trim()
: "Untitled conversation";
const transcript = activeBranchMessages(conversation);
const userTexts = transcript.filter((entry) => entry.role === "user").map((entry) => entry.text);
const assistantTexts = transcript.filter((entry) => entry.role === "assistant");
const sampleText = userTexts.slice(0, 6).join("\n");
const risk = inferRisk(title, sampleText);
const labels = inferLabels(title, sampleText);
const { pageId, pagePath } = resolveConversationPagePath({
conversationId,
createdAt: isoFromUnix(conversation.create_time),
});
return {
conversationId,
title,
createdAt: isoFromUnix(conversation.create_time),
updatedAt: isoFromUnix(conversation.update_time) ?? isoFromUnix(conversation.create_time),
sourcePath,
pageId,
pagePath,
labels,
risk,
userMessageCount: userTexts.length,
assistantMessageCount: assistantTexts.length,
preferenceSignals: risk.level === "low" ? collectPreferenceSignals(userTexts) : [],
firstUserLine: userTexts[0]?.split(/\r?\n/)[0]?.trim(),
lastUserLine: userTexts.at(-1)?.split(/\r?\n/)[0]?.trim(),
transcript,
};
}
function renderConversationPage(record: ChatGptConversationRecord): string {
const autoDigestLines =
record.risk.level === "low"
? [
`- User messages: ${record.userMessageCount}`,
`- Assistant messages: ${record.assistantMessageCount}`,
...(record.firstUserLine ? [`- First user line: ${record.firstUserLine}`] : []),
...(record.lastUserLine ? [`- Last user line: ${record.lastUserLine}`] : []),
...(record.preferenceSignals.length > 0
? ["- Preference signals:", ...record.preferenceSignals.map((line) => ` - ${line}`)]
: ["- Preference signals: none detected"]),
]
: [
"- Auto digest withheld from durable-candidate generation until reviewed.",
`- Risk reasons: ${record.risk.reasons.length > 0 ? record.risk.reasons.join(", ") : "none recorded"}`,
];
return renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: record.pageId,
title: `ChatGPT Export: ${record.title}`,
sourceType: "chatgpt-export",
sourceSystem: "chatgpt",
sourcePath: record.sourcePath,
conversationId: record.conversationId,
riskLevel: record.risk.level,
riskReasons: record.risk.reasons,
labels: record.labels,
status: "draft",
...(record.createdAt ? { createdAt: record.createdAt } : {}),
...(record.updatedAt ? { updatedAt: record.updatedAt } : {}),
},
body: [
`# ChatGPT Export: ${record.title}`,
"",
"## Source",
`- Conversation id: \`${record.conversationId}\``,
`- Export file: \`${record.sourcePath}\``,
...(record.createdAt ? [`- Created: ${record.createdAt}`] : []),
...(record.updatedAt ? [`- Updated: ${record.updatedAt}`] : []),
"",
"## Auto Triage",
`- Risk level: \`${record.risk.level}\``,
`- Labels: ${record.labels.join(", ")}`,
`- Active-branch messages: ${record.transcript.length}`,
"",
"## Auto Digest",
...autoDigestLines,
"",
"## Active Branch Transcript",
buildTranscript(record.transcript),
"",
"## Notes",
HUMAN_START_MARKER,
HUMAN_END_MARKER,
"",
].join("\n"),
});
}
function replaceSimpleManagedBlock(params: {
original: string;
startMarker: string;
endMarker: string;
replacement: string;
}): string {
const escapedStart = params.startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedEnd = params.endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`);
return params.original.replace(blockPattern, params.replacement);
}
function extractSimpleManagedBlock(params: {
body: string;
startMarker: string;
endMarker: string;
}): string | null {
const escapedStart = params.startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedEnd = params.endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`);
return params.body.match(blockPattern)?.[0] ?? null;
}
function extractManagedBlockBody(params: {
body: string;
startMarker: string;
endMarker: string;
}): string | null {
const escapedStart = params.startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedEnd = params.endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockPattern = new RegExp(`${escapedStart}\\n?([\\s\\S]*?)\\n?${escapedEnd}`);
const captured = params.body.match(blockPattern)?.[1];
return typeof captured === "string" ? captured.trim() : null;
}
function preserveExistingPageBlocks(rendered: string, existing: string): string {
if (!existing.trim()) {
return withTrailingNewline(rendered);
}
const parsedExisting = parseWikiMarkdown(existing);
const parsedRendered = parseWikiMarkdown(rendered);
let nextBody = parsedRendered.body;
const humanBlock = extractSimpleManagedBlock({
body: parsedExisting.body,
startMarker: HUMAN_START_MARKER,
endMarker: HUMAN_END_MARKER,
});
if (humanBlock) {
nextBody = replaceSimpleManagedBlock({
original: nextBody,
startMarker: HUMAN_START_MARKER,
endMarker: HUMAN_END_MARKER,
replacement: humanBlock,
});
}
const relatedBody = extractManagedBlockBody({
body: parsedExisting.body,
startMarker: WIKI_RELATED_START_MARKER,
endMarker: WIKI_RELATED_END_MARKER,
});
if (relatedBody) {
nextBody = replaceManagedMarkdownBlock({
original: nextBody,
heading: "## Related",
startMarker: WIKI_RELATED_START_MARKER,
endMarker: WIKI_RELATED_END_MARKER,
body: relatedBody,
});
}
return withTrailingNewline(
renderWikiMarkdown({
frontmatter: parsedRendered.frontmatter,
body: nextBody,
}),
);
}
function buildRunId(exportPath: string, nowIso: string): string {
const seed = `${exportPath}:${nowIso}:${Math.random()}`;
return `chatgpt-${createHash("sha1").update(seed).digest("hex").slice(0, 12)}`;
}
function resolveImportRunsDir(vaultRoot: string): string {
return path.join(vaultRoot, ".openclaw-wiki", "import-runs");
}
function resolveImportRunPath(vaultRoot: string, runId: string): string {
return path.join(resolveImportRunsDir(vaultRoot), `${runId}.json`);
}
function normalizeConversationActions(
records: ChatGptConversationRecord[],
operations: Map<string, ChatGptImportOperation>,
): ChatGptImportAction[] {
return records.map((record) => ({
conversationId: record.conversationId,
title: record.title,
pagePath: record.pagePath,
operation: operations.get(record.pagePath) ?? "skip",
riskLevel: record.risk.level,
labels: record.labels,
userMessageCount: record.userMessageCount,
assistantMessageCount: record.assistantMessageCount,
preferenceSignals: record.preferenceSignals,
}));
}
async function writeImportRunRecord(
vaultRoot: string,
record: ChatGptImportRunRecord,
): Promise<void> {
const recordPath = resolveImportRunPath(vaultRoot, record.runId);
await fs.mkdir(path.dirname(recordPath), { recursive: true });
await fs.writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
}
async function readImportRunRecord(
vaultRoot: string,
runId: string,
): Promise<ChatGptImportRunRecord> {
const recordPath = resolveImportRunPath(vaultRoot, runId);
const raw = await fs.readFile(recordPath, "utf8");
return JSON.parse(raw) as ChatGptImportRunRecord;
}
async function writeTrackedImportPage(params: {
vaultRoot: string;
runDir: string;
relativePath: string;
content: string;
record: ChatGptImportRunRecord;
}): Promise<ChatGptImportOperation> {
const absolutePath = path.join(params.vaultRoot, params.relativePath);
const existing = await fs.readFile(absolutePath, "utf8").catch(() => "");
const rendered = preserveExistingPageBlocks(params.content, existing);
if (existing === rendered) {
return "skip";
}
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
if (!existing) {
await fs.writeFile(absolutePath, rendered, "utf8");
params.record.createdPaths.push(params.relativePath);
return "create";
}
const snapshotHash = createHash("sha1").update(params.relativePath).digest("hex").slice(0, 12);
const snapshotRelativePath = path.join("snapshots", `${snapshotHash}.md`).replace(/\\/g, "/");
const snapshotAbsolutePath = path.join(params.runDir, snapshotRelativePath);
await fs.mkdir(path.dirname(snapshotAbsolutePath), { recursive: true });
await fs.writeFile(snapshotAbsolutePath, existing, "utf8");
await fs.writeFile(absolutePath, rendered, "utf8");
params.record.updatedPaths.push({
path: params.relativePath,
snapshotPath: snapshotRelativePath,
});
return "update";
}
export async function importChatGptConversations(params: {
config: ResolvedMemoryWikiConfig;
exportPath: string;
dryRun?: boolean;
nowMs?: number;
}): Promise<ChatGptImportResult> {
await initializeMemoryWikiVault(params.config, { nowMs: params.nowMs });
const { exportPath, conversationsPath, conversations } = await loadConversations(
params.exportPath,
);
const records = conversations
.map((conversation) => toConversationRecord(conversation, conversationsPath))
.filter((entry): entry is ChatGptConversationRecord => entry !== null)
.toSorted((left, right) => left.pagePath.localeCompare(right.pagePath));
const operations = new Map<string, ChatGptImportOperation>();
let createdCount = 0;
let updatedCount = 0;
let skippedCount = 0;
let runId: string | undefined;
const nowIso = new Date(params.nowMs ?? Date.now()).toISOString();
let importRunRecord: ChatGptImportRunRecord | undefined;
let importRunDir = "";
if (!params.dryRun) {
runId = buildRunId(exportPath, nowIso);
importRunDir = path.join(resolveImportRunsDir(params.config.vault.path), runId);
importRunRecord = {
version: 1,
runId,
importType: "chatgpt",
exportPath,
sourcePath: conversationsPath,
appliedAt: nowIso,
conversationCount: records.length,
createdCount: 0,
updatedCount: 0,
skippedCount: 0,
createdPaths: [],
updatedPaths: [],
};
}
for (const record of records) {
const rendered = renderConversationPage(record);
const absolutePath = path.join(params.config.vault.path, record.pagePath);
const existing = await fs.readFile(absolutePath, "utf8").catch(() => "");
const stabilized = preserveExistingPageBlocks(rendered, existing);
const operation: ChatGptImportOperation =
existing === stabilized ? "skip" : existing ? "update" : "create";
operations.set(record.pagePath, operation);
if (operation === "create") {
createdCount += 1;
} else if (operation === "update") {
updatedCount += 1;
} else {
skippedCount += 1;
}
if (!params.dryRun && importRunRecord) {
await writeTrackedImportPage({
vaultRoot: params.config.vault.path,
runDir: importRunDir,
relativePath: record.pagePath,
content: rendered,
record: importRunRecord,
});
}
}
let indexUpdatedFiles: string[] = [];
if (!params.dryRun && importRunRecord) {
importRunRecord.createdCount = createdCount;
importRunRecord.updatedCount = updatedCount;
importRunRecord.skippedCount = skippedCount;
if (importRunRecord.createdPaths.length > 0 || importRunRecord.updatedPaths.length > 0) {
const compile = await compileMemoryWikiVault(params.config);
indexUpdatedFiles = compile.updatedFiles;
await writeImportRunRecord(params.config.vault.path, importRunRecord);
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp: nowIso,
details: {
sourceType: "chatgpt-export",
runId: importRunRecord.runId,
exportPath,
sourcePath: conversationsPath,
conversationCount: records.length,
createdCount: importRunRecord.createdPaths.length,
updatedCount: importRunRecord.updatedPaths.length,
skippedCount,
},
});
} else {
runId = undefined;
}
}
return {
dryRun: Boolean(params.dryRun),
exportPath,
sourcePath: conversationsPath,
conversationCount: records.length,
createdCount,
updatedCount,
skippedCount,
actions: normalizeConversationActions(records, operations),
pagePaths: records.map((record) => record.pagePath),
...(runId ? { runId } : {}),
indexUpdatedFiles,
};
}
export async function rollbackChatGptImportRun(params: {
config: ResolvedMemoryWikiConfig;
runId: string;
}): Promise<ChatGptRollbackResult> {
await initializeMemoryWikiVault(params.config);
const record = await readImportRunRecord(params.config.vault.path, params.runId);
if (record.rolledBackAt) {
return {
runId: record.runId,
removedCount: 0,
restoredCount: 0,
pagePaths: [
...record.createdPaths,
...record.updatedPaths.map((entry) => entry.path),
].toSorted((left, right) => left.localeCompare(right)),
indexUpdatedFiles: [],
alreadyRolledBack: true,
};
}
let removedCount = 0;
for (const relativePath of record.createdPaths) {
await fs
.rm(path.join(params.config.vault.path, relativePath), { force: true })
.catch(() => undefined);
removedCount += 1;
}
let restoredCount = 0;
const runDir = path.join(resolveImportRunsDir(params.config.vault.path), record.runId);
for (const entry of record.updatedPaths) {
if (!entry.snapshotPath) {
continue;
}
const snapshotPath = path.join(runDir, entry.snapshotPath);
const snapshot = await fs.readFile(snapshotPath, "utf8");
const targetPath = path.join(params.config.vault.path, entry.path);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, snapshot, "utf8");
restoredCount += 1;
}
const compile = await compileMemoryWikiVault(params.config);
record.rolledBackAt = new Date().toISOString();
await writeImportRunRecord(params.config.vault.path, record);
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp: record.rolledBackAt,
details: {
sourceType: "chatgpt-export",
runId: record.runId,
rollback: true,
removedCount,
restoredCount,
},
});
return {
runId: record.runId,
removedCount,
restoredCount,
pagePaths: [...record.createdPaths, ...record.updatedPaths.map((entry) => entry.path)].toSorted(
(left, right) => left.localeCompare(right),
),
indexUpdatedFiles: compile.updatedFiles,
alreadyRolledBack: false,
};
}

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerWikiCli } from "./cli.js";
import { registerWikiCli, runWikiChatGptImport, runWikiChatGptRollback } from "./cli.js";
import type { MemoryWikiPluginConfig } from "./config.js";
import { parseWikiMarkdown, renderWikiMarkdown } from "./markdown.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
@@ -47,6 +47,47 @@ describe("memory-wiki cli", () => {
});
}
async function createChatGptExport(rootDir: string) {
const exportDir = path.join(rootDir, "chatgpt-export");
await fs.mkdir(exportDir, { recursive: true });
const conversations = [
{
conversation_id: "12345678-1234-1234-1234-1234567890ab",
title: "Travel preference check",
create_time: 1_712_363_200,
update_time: 1_712_366_800,
current_node: "assistant-1",
mapping: {
root: {},
"user-1": {
parent: "root",
message: {
author: { role: "user" },
content: {
parts: ["I prefer aisle seats and I don't want a hotel far from the airport."],
},
},
},
"assistant-1": {
parent: "user-1",
message: {
author: { role: "assistant" },
content: {
parts: ["Noted. I will keep travel options close to the airport."],
},
},
},
},
},
];
await fs.writeFile(
path.join(exportDir, "conversations.json"),
`${JSON.stringify(conversations, null, 2)}\n`,
"utf8",
);
return exportDir;
}
it("registers apply synthesis and writes a synthesis page", async () => {
const { rootDir, config } = await createCliVault();
const program = new Command();
@@ -153,4 +194,57 @@ cli note
expect(process.exitCode).toBe(1);
});
it("imports ChatGPT exports with dry-run, apply, and rollback", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
const dryRun = await runWikiChatGptImport({
config,
exportPath: exportDir,
dryRun: true,
json: true,
});
expect(dryRun.dryRun).toBe(true);
expect(dryRun.createdCount).toBe(1);
await expect(fs.readdir(path.join(rootDir, "sources"))).resolves.toEqual([]);
const applied = await runWikiChatGptImport({
config,
exportPath: exportDir,
json: true,
});
expect(applied.runId).toBeTruthy();
expect(applied.createdCount).toBe(1);
const sourceFiles = (await fs.readdir(path.join(rootDir, "sources"))).filter(
(entry) => entry !== "index.md",
);
expect(sourceFiles).toHaveLength(1);
const pageContent = await fs.readFile(path.join(rootDir, "sources", sourceFiles[0]!), "utf8");
expect(pageContent).toContain("ChatGPT Export: Travel preference check");
expect(pageContent).toContain("I prefer aisle seats");
expect(pageContent).toContain("Preference signals:");
const secondDryRun = await runWikiChatGptImport({
config,
exportPath: exportDir,
dryRun: true,
json: true,
});
expect(secondDryRun.createdCount).toBe(0);
expect(secondDryRun.updatedCount).toBe(0);
expect(secondDryRun.skippedCount).toBe(1);
const rollback = await runWikiChatGptRollback({
config,
runId: applied.runId!,
json: true,
});
expect(rollback.alreadyRolledBack).toBe(false);
await expect(
fs
.readdir(path.join(rootDir, "sources"))
.then((entries) => entries.filter((entry) => entry !== "index.md")),
).resolves.toEqual([]);
});
});

View File

@@ -2,6 +2,12 @@ import fs from "node:fs/promises";
import type { Command } from "commander";
import type { OpenClawConfig } from "../api.js";
import { applyMemoryWikiMutation } from "./apply.js";
import {
importChatGptConversations,
rollbackChatGptImportRun,
type ChatGptImportResult,
type ChatGptRollbackResult,
} from "./chatgpt-import.js";
import { compileMemoryWikiVault } from "./compile.js";
import {
resolveMemoryWikiConfig,
@@ -98,6 +104,16 @@ type WikiUnsafeLocalImportCommandOptions = {
json?: boolean;
};
type WikiChatGptImportCommandOptions = {
json?: boolean;
dryRun?: boolean;
export?: string;
};
type WikiChatGptRollbackCommandOptions = {
json?: boolean;
};
type WikiObsidianSearchCommandOptions = {
json?: boolean;
};
@@ -592,6 +608,59 @@ export async function runWikiObsidianDailyCli(params: {
});
}
function formatChatGptImportSummary(result: ChatGptImportResult): string {
if (result.dryRun) {
return `ChatGPT import dry run scanned ${result.conversationCount} conversations (${result.createdCount} new, ${result.updatedCount} updated, ${result.skippedCount} unchanged).`;
}
const runSuffix = result.runId ? ` Run id: ${result.runId}.` : "";
return `ChatGPT import applied ${result.conversationCount} conversations (${result.createdCount} new, ${result.updatedCount} updated, ${result.skippedCount} unchanged). Refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.${runSuffix}`;
}
function formatChatGptRollbackSummary(result: ChatGptRollbackResult): string {
if (result.alreadyRolledBack) {
return `ChatGPT import run ${result.runId} was already rolled back.`;
}
return `Rolled back ChatGPT import run ${result.runId} (${result.removedCount} removed, ${result.restoredCount} restored). Refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.`;
}
export async function runWikiChatGptImport(params: {
config: ResolvedMemoryWikiConfig;
exportPath: string;
dryRun?: boolean;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
run: () =>
importChatGptConversations({
config: params.config,
exportPath: params.exportPath,
dryRun: params.dryRun,
}),
render: formatChatGptImportSummary,
});
}
export async function runWikiChatGptRollback(params: {
config: ResolvedMemoryWikiConfig;
runId: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
run: () =>
rollbackChatGptImportRun({
config: params.config,
runId: params.runId,
}),
render: formatChatGptRollbackSummary,
});
}
export function registerWikiCli(
program: Command,
pluginConfig?: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig,
@@ -764,6 +833,36 @@ export function registerWikiCli(
await runWikiUnsafeLocalImport({ config, appConfig, json: opts.json });
});
const chatgpt = wiki
.command("chatgpt")
.description("Import ChatGPT export history into wiki source pages");
chatgpt
.command("import")
.description("Import a ChatGPT export into draft wiki source pages")
.requiredOption("--export <path>", "ChatGPT export directory or conversations.json path")
.option("--dry-run", "Preview changes without writing", false)
.option("--json", "Print JSON")
.action(async (opts: WikiChatGptImportCommandOptions) => {
await runWikiChatGptImport({
config,
exportPath: opts.export!,
dryRun: opts.dryRun,
json: opts.json,
});
});
chatgpt
.command("rollback")
.description("Roll back a previously applied ChatGPT import run")
.argument("<run-id>", "Import run id")
.option("--json", "Print JSON")
.action(async (runId: string, opts: WikiChatGptRollbackCommandOptions) => {
await runWikiChatGptRollback({
config,
runId,
json: opts.json,
});
});
const obsidian = wiki.command("obsidian").description("Run official Obsidian CLI helpers");
obsidian
.command("status")

View File

@@ -5,7 +5,10 @@ import {
type ApplyMemoryWikiMutation,
} from "./apply.js";
import { registerMemoryWikiGatewayMethods } from "./gateway.js";
import { listMemoryWikiImportInsights } from "./import-insights.js";
import { listMemoryWikiImportRuns } from "./import-runs.js";
import { ingestMemoryWikiSource } from "./ingest.js";
import { listMemoryWikiPalace } from "./memory-palace.js";
import { searchMemoryWiki } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import { resolveMemoryWikiStatus } from "./status.js";
@@ -24,10 +27,22 @@ vi.mock("./ingest.js", () => ({
ingestMemoryWikiSource: vi.fn(),
}));
vi.mock("./import-insights.js", () => ({
listMemoryWikiImportInsights: vi.fn(),
}));
vi.mock("./import-runs.js", () => ({
listMemoryWikiImportRuns: vi.fn(),
}));
vi.mock("./lint.js", () => ({
lintMemoryWikiVault: vi.fn(),
}));
vi.mock("./memory-palace.js", () => ({
listMemoryWikiPalace: vi.fn(),
}));
vi.mock("./obsidian.js", () => ({
probeObsidianCli: vi.fn(),
runObsidianCommand: vi.fn(),
@@ -90,6 +105,25 @@ describe("memory-wiki gateway methods", () => {
vi.mocked(ingestMemoryWikiSource).mockResolvedValue({
pagePath: "sources/alpha-notes.md",
} as never);
vi.mocked(listMemoryWikiImportRuns).mockResolvedValue({
runs: [],
totalRuns: 0,
activeRuns: 0,
rolledBackRuns: 0,
} as never);
vi.mocked(listMemoryWikiImportInsights).mockResolvedValue({
sourceType: "chatgpt",
totalItems: 0,
totalClusters: 0,
clusters: [],
} as never);
vi.mocked(listMemoryWikiPalace).mockResolvedValue({
totalItems: 0,
totalClaims: 0,
totalQuestions: 0,
totalContradictions: 0,
clusters: [],
} as never);
vi.mocked(normalizeMemoryWikiMutationInput).mockReturnValue({
op: "create_synthesis",
title: "Gateway Alpha",
@@ -135,6 +169,169 @@ describe("memory-wiki gateway methods", () => {
);
});
it("returns recent import runs over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
vi.mocked(listMemoryWikiImportRuns).mockResolvedValue({
runs: [
{
runId: "chatgpt-abc123",
importType: "chatgpt",
appliedAt: "2026-04-10T10:00:00.000Z",
exportPath: "/tmp/chatgpt",
sourcePath: "/tmp/chatgpt/conversations.json",
conversationCount: 12,
createdCount: 4,
updatedCount: 2,
skippedCount: 6,
status: "applied",
pagePaths: ["sources/chatgpt-2026-04-10-alpha.md"],
samplePaths: ["sources/chatgpt-2026-04-10-alpha.md"],
},
],
totalRuns: 1,
activeRuns: 1,
rolledBackRuns: 0,
} as never);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.importRuns");
if (!handler) {
throw new Error("wiki.importRuns handler missing");
}
const respond = vi.fn();
await handler({
params: {
limit: 5,
},
respond,
});
expect(listMemoryWikiImportRuns).toHaveBeenCalledWith(config, { limit: 5 });
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
totalRuns: 1,
activeRuns: 1,
}),
);
});
it("returns import insights over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
vi.mocked(listMemoryWikiImportInsights).mockResolvedValue({
sourceType: "chatgpt",
totalItems: 2,
totalClusters: 1,
clusters: [
{
key: "topic/travel",
label: "Travel",
itemCount: 2,
highRiskCount: 1,
withheldCount: 1,
preferenceSignalCount: 0,
updatedAt: "2026-04-10T10:00:00.000Z",
items: [
{
pagePath: "sources/chatgpt-2026-04-10-alpha.md",
title: "BA flight receipts process",
riskLevel: "low",
labels: ["domain/personal", "area/travel", "topic/travel"],
topicKey: "topic/travel",
topicLabel: "Travel",
digestStatus: "available",
firstUserLine: "how do i get receipts?",
lastUserLine: "that option does not exist",
preferenceSignals: [],
},
],
},
],
} as never);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.importInsights");
if (!handler) {
throw new Error("wiki.importInsights handler missing");
}
const respond = vi.fn();
await handler({
params: {},
respond,
});
expect(syncMemoryWikiImportedSources).toHaveBeenCalledWith({ config, appConfig: undefined });
expect(listMemoryWikiImportInsights).toHaveBeenCalledWith(config);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
sourceType: "chatgpt",
totalItems: 2,
totalClusters: 1,
}),
);
});
it("returns memory palace overview over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
vi.mocked(listMemoryWikiPalace).mockResolvedValue({
totalItems: 3,
totalClaims: 4,
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: [],
},
],
},
],
} as never);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.palace");
if (!handler) {
throw new Error("wiki.palace handler missing");
}
const respond = vi.fn();
await handler({
params: {},
respond,
});
expect(syncMemoryWikiImportedSources).toHaveBeenCalledWith({ config, appConfig: undefined });
expect(listMemoryWikiPalace).toHaveBeenCalledWith(config);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
totalItems: 3,
totalClaims: 4,
}),
);
});
it("validates required query params for wiki.search", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();

View File

@@ -7,8 +7,11 @@ import {
WIKI_SEARCH_CORPORA,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { listMemoryWikiImportInsights } from "./import-insights.js";
import { listMemoryWikiImportRuns } from "./import-runs.js";
import { ingestMemoryWikiSource } from "./ingest.js";
import { lintMemoryWikiVault } from "./lint.js";
import { listMemoryWikiPalace } from "./memory-palace.js";
import {
probeObsidianCli,
runObsidianCommand,
@@ -115,6 +118,45 @@ export function registerMemoryWikiGatewayMethods(params: {
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.importRuns",
async ({ params: requestParams, respond }) => {
try {
const limit = readNumberParam(requestParams, "limit");
respond(true, await listMemoryWikiImportRuns(config, limit !== undefined ? { limit } : {}));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.importInsights",
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(true, await listMemoryWikiImportInsights(config));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.palace",
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(true, await listMemoryWikiPalace(config));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.init",
async ({ respond }) => {

View File

@@ -0,0 +1,142 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listMemoryWikiImportInsights } from "./import-insights.js";
import { renderWikiMarkdown } from "./markdown.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createVault } = createMemoryWikiTestHarness();
describe("listMemoryWikiImportInsights", () => {
it("clusters ChatGPT import pages by topic and extracts digest fields", async () => {
const { rootDir, config } = await createVault({
prefix: "memory-wiki-import-insights-",
initialize: true,
});
await fs.mkdir(path.join(rootDir, "sources"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "sources", "chatgpt-travel.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.chatgpt.travel",
title: "ChatGPT Export: BA flight receipts process",
sourceType: "chatgpt-export",
riskLevel: "low",
riskReasons: [],
labels: ["domain/personal", "area/travel", "topic/travel"],
createdAt: "2026-01-11T14:07:58.552Z",
updatedAt: "2026-01-11T14:08:45.377Z",
},
body: [
"# ChatGPT Export: BA flight receipts process",
"",
"## Auto Digest",
"- User messages: 2",
"- Assistant messages: 2",
"- First user line: how do i get receipts?",
"- Last user line: that option does not exist",
"- Preference signals:",
" - prefers direct airline receipts",
"",
"## Active Branch Transcript",
"### User",
"",
"how do i get receipts?",
"",
"### Assistant",
"",
"Try the BA receipt request flow first.",
"",
].join("\n"),
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "chatgpt-health.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.chatgpt.health",
title: "ChatGPT Export: Migraine Medication Advice",
sourceType: "chatgpt-export",
riskLevel: "high",
riskReasons: ["health"],
labels: ["domain/personal", "area/health", "topic/health"],
updatedAt: "2026-01-31T20:18:00.000Z",
},
body: [
"# ChatGPT Export: Migraine Medication Advice",
"",
"## Auto Digest",
"- Auto digest withheld from durable-candidate generation until reviewed.",
"- Risk reasons: health content",
"- First user line: i have a migraine, pink or yellow?",
"- Last user line: should i take this now?",
"- Preference signals:",
" - prefers color-coded medication guidance",
"",
"## Active Branch Transcript",
"### User",
"",
"i have a migraine, pink or yellow?",
"",
"### Assistant",
"",
"You're right, let's reset and stick to safe dosing guidance.",
"",
].join("\n"),
}),
"utf8",
);
const result = await listMemoryWikiImportInsights(config);
expect(result.sourceType).toBe("chatgpt");
expect(result.totalItems).toBe(2);
expect(result.totalClusters).toBe(2);
expect(result.clusters[0]).toMatchObject({
key: "topic/health",
label: "Health",
itemCount: 1,
highRiskCount: 1,
withheldCount: 1,
});
expect(result.clusters[1]).toMatchObject({
key: "topic/travel",
label: "Travel",
itemCount: 1,
preferenceSignalCount: 1,
});
expect(result.clusters[1]?.items[0]).toMatchObject({
title: "BA flight receipts process",
riskReasons: [],
activeBranchMessages: 0,
userMessageCount: 2,
assistantMessageCount: 2,
firstUserLine: "how do i get receipts?",
lastUserLine: "that option does not exist",
assistantOpener: "Try the BA receipt request flow first.",
summary: "Try the BA receipt request flow first.",
candidateSignals: ["prefers direct airline receipts"],
correctionSignals: [],
preferenceSignals: ["prefers direct airline receipts"],
digestStatus: "available",
});
const healthItem = result.clusters
.flatMap((cluster) => cluster.items)
.find((item) => item.title === "Migraine Medication Advice");
expect(healthItem).toMatchObject({
summary:
"Sensitive health chat withheld from durable-memory extraction because it touches health.",
candidateSignals: [],
correctionSignals: [],
preferenceSignals: [],
userMessageCount: 1,
assistantMessageCount: 1,
});
expect(healthItem?.firstUserLine).toBeUndefined();
expect(healthItem?.lastUserLine).toBeUndefined();
expect(healthItem?.assistantOpener).toBeUndefined();
});
});

View File

@@ -0,0 +1,438 @@
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown } from "./markdown.js";
import { readQueryableWikiPages } from "./query.js";
export type MemoryWikiImportInsightItem = {
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 MemoryWikiImportInsightCluster = {
key: string;
label: string;
itemCount: number;
highRiskCount: number;
withheldCount: number;
preferenceSignalCount: number;
updatedAt?: string;
items: MemoryWikiImportInsightItem[];
};
export type MemoryWikiImportInsightsStatus = {
sourceType: "chatgpt";
totalItems: number;
totalClusters: number;
clusters: MemoryWikiImportInsightCluster[];
};
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
}
function normalizeFiniteInt(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.floor(value));
}
function normalizeTimestamp(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function humanizeLabelSuffix(label: string): string {
const suffix = label.includes("/") ? label.split("/").slice(1).join("/") : label;
return suffix
.split(/[/-]/g)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function resolveTopic(labels: string[]): { key: string; label: string } {
const preferred =
labels.find((label) => label.startsWith("topic/")) ??
labels.find((label) => label.startsWith("area/")) ??
labels.find((label) => label.startsWith("domain/")) ??
"topic/other";
return {
key: preferred,
label: humanizeLabelSuffix(preferred),
};
}
function extractHeadingSection(body: string, heading: string): string[] {
const lines = body.split(/\r?\n/);
const headingLine = `## ${heading}`;
const startIndex = lines.findIndex((line) => line.trim() === headingLine);
if (startIndex < 0) {
return [];
}
const section: string[] = [];
for (const line of lines.slice(startIndex + 1)) {
if (line.startsWith("## ")) {
break;
}
if (line.trim().length > 0) {
section.push(line.trimEnd());
}
}
return section;
}
function extractDigestField(lines: string[], prefix: string): string | undefined {
const needle = `- ${prefix}:`;
const line = lines.find((entry) => entry.startsWith(needle));
if (!line) {
return undefined;
}
const value = line.slice(needle.length).trim();
return value.length > 0 ? value : undefined;
}
function extractIntegerField(lines: string[], prefix: string): number {
const raw = extractDigestField(lines, prefix);
if (!raw) {
return 0;
}
const match = raw.match(/\d+/);
return match ? normalizeFiniteInt(Number(match[0])) : 0;
}
function extractPreferenceSignals(lines: string[]): string[] {
const startIndex = lines.findIndex((line) => line.startsWith("- Preference signals:"));
if (startIndex < 0) {
return [];
}
if (lines[startIndex]?.includes("none detected")) {
return [];
}
const signals: string[] = [];
for (const line of lines.slice(startIndex + 1)) {
const trimmed = line.trim();
if (!trimmed.startsWith("- ")) {
break;
}
const signal = trimmed.slice(2).trim();
if (signal.length > 0) {
signals.push(signal);
}
}
return signals;
}
type TranscriptTurn = {
role: "user" | "assistant";
text: string;
};
function parseTranscriptTurns(body: string): TranscriptTurn[] {
const transcriptLines = extractHeadingSection(body, "Active Branch Transcript");
if (transcriptLines.length === 0) {
return [];
}
const turns: TranscriptTurn[] = [];
let currentRole: TranscriptTurn["role"] | null = null;
let currentLines: string[] = [];
const flush = () => {
if (!currentRole) {
currentLines = [];
return;
}
const text = currentLines.join("\n").trim();
if (text) {
turns.push({ role: currentRole, text });
}
currentLines = [];
};
for (const rawLine of transcriptLines) {
const line = rawLine.trimEnd();
if (line.trim() === "### User") {
flush();
currentRole = "user";
continue;
}
if (line.trim() === "### Assistant") {
flush();
currentRole = "assistant";
continue;
}
if (currentRole) {
currentLines.push(line);
}
}
flush();
return turns;
}
function firstParagraph(text: string): string | undefined {
const candidate = text
.split(/\n\s*\n/)
.map((entry) => entry.trim())
.find((entry) => entry.length > 0);
return candidate;
}
function shortenSentence(value: string, maxLength = 180): string {
const compact = value.replace(/\s+/g, " ").trim();
if (compact.length <= maxLength) {
return compact;
}
return `${compact.slice(0, maxLength - 1).trimEnd()}`;
}
function extractCorrectionSignals(turns: TranscriptTurn[]): string[] {
const correctionPatterns = [
"you're right",
"youre right",
"bad assumption",
"let's reset",
"lets reset",
"does not exist anymore",
"that was a bad assumption",
"what actually works today",
];
return turns
.filter((turn) => turn.role === "assistant")
.flatMap((turn) => {
const first = firstParagraph(turn.text);
if (!first) {
return [];
}
const normalized = first.toLowerCase();
return correctionPatterns.some((pattern) => normalized.includes(pattern))
? [shortenSentence(first, 160)]
: [];
})
.slice(0, 2);
}
function deriveCandidateSignals(params: {
preferenceSignals: string[];
correctionSignals: string[];
}): string[] {
const output: string[] = [];
for (const signal of params.preferenceSignals) {
if (!output.includes(signal)) {
output.push(signal);
}
}
for (const correction of params.correctionSignals) {
const summary = `Correction detected: ${correction}`;
if (!output.includes(summary)) {
output.push(summary);
}
}
return output.slice(0, 4);
}
function deriveSummary(params: {
title: string;
digestStatus: "available" | "withheld";
assistantOpener?: string;
firstUserLine?: string;
riskReasons: string[];
topicLabel: string;
}): string {
if (params.digestStatus === "withheld") {
if (params.riskReasons.length > 0) {
return `Sensitive ${params.topicLabel.toLowerCase()} chat withheld from durable-memory extraction because it touches ${params.riskReasons.join(", ")}.`;
}
return `Sensitive ${params.topicLabel.toLowerCase()} chat withheld from durable-memory extraction pending review.`;
}
if (params.assistantOpener) {
return shortenSentence(params.assistantOpener, 180);
}
if (params.firstUserLine) {
return shortenSentence(params.firstUserLine, 180);
}
return params.title;
}
function shouldExposeImportContent(digestStatus: "available" | "withheld"): boolean {
return digestStatus === "available";
}
function normalizeRiskLevel(value: unknown): MemoryWikiImportInsightItem["riskLevel"] {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return "unknown";
}
function compareItemsByUpdated(
left: MemoryWikiImportInsightItem,
right: MemoryWikiImportInsightItem,
): number {
const leftKey = left.updatedAt ?? left.createdAt ?? "";
const rightKey = right.updatedAt ?? right.createdAt ?? "";
if (rightKey !== leftKey) {
return rightKey.localeCompare(leftKey);
}
return left.title.localeCompare(right.title);
}
export async function listMemoryWikiImportInsights(
config: ResolvedMemoryWikiConfig,
): Promise<MemoryWikiImportInsightsStatus> {
const pages = await readQueryableWikiPages(config.vault.path);
const items = pages
.flatMap((page) => {
if (page.pageType !== "source") {
return [];
}
const parsed = parseWikiMarkdown(page.raw);
if (parsed.frontmatter.sourceType !== "chatgpt-export") {
return [];
}
const labels = normalizeStringArray(parsed.frontmatter.labels);
const topic = resolveTopic(labels);
const triageLines = extractHeadingSection(parsed.body, "Auto Triage");
const digestLines = extractHeadingSection(parsed.body, "Auto Digest");
const transcriptTurns = parseTranscriptTurns(parsed.body);
const digestStatus = digestLines.some((line) =>
line.toLowerCase().includes("withheld from durable-candidate generation"),
)
? "withheld"
: "available";
const exposeImportContent = shouldExposeImportContent(digestStatus);
const userTurns = transcriptTurns.filter((turn) => turn.role === "user");
const assistantTurns = transcriptTurns.filter((turn) => turn.role === "assistant");
const assistantOpener = exposeImportContent
? firstParagraph(assistantTurns[0]?.text ?? "")
: undefined;
const correctionSignals = exposeImportContent
? extractCorrectionSignals(transcriptTurns)
: [];
const preferenceSignals = exposeImportContent ? extractPreferenceSignals(digestLines) : [];
const candidateSignals = exposeImportContent
? deriveCandidateSignals({
preferenceSignals,
correctionSignals,
})
: [];
const firstUserLine = exposeImportContent
? extractDigestField(digestLines, "First user line")
: undefined;
const lastUserLine = exposeImportContent
? extractDigestField(digestLines, "Last user line")
: undefined;
return [
{
pagePath: page.relativePath,
title: page.title.replace(/^ChatGPT Export:\s*/i, ""),
riskLevel: normalizeRiskLevel(parsed.frontmatter.riskLevel),
riskReasons: normalizeStringArray(parsed.frontmatter.riskReasons),
labels,
topicKey: topic.key,
topicLabel: topic.label,
digestStatus,
activeBranchMessages: extractIntegerField(triageLines, "Active-branch messages"),
userMessageCount: Math.max(
extractIntegerField(digestLines, "User messages"),
userTurns.length,
),
assistantMessageCount: Math.max(
extractIntegerField(digestLines, "Assistant messages"),
assistantTurns.length,
),
...(firstUserLine ? { firstUserLine } : {}),
...(lastUserLine ? { lastUserLine } : {}),
...(assistantOpener ? { assistantOpener } : {}),
summary: deriveSummary({
title: page.title.replace(/^ChatGPT Export:\s*/i, ""),
digestStatus,
...(assistantOpener ? { assistantOpener } : {}),
...(firstUserLine ? { firstUserLine } : {}),
riskReasons: normalizeStringArray(parsed.frontmatter.riskReasons),
topicLabel: topic.label,
}),
candidateSignals,
correctionSignals,
preferenceSignals,
...(normalizeTimestamp(parsed.frontmatter.createdAt)
? { createdAt: normalizeTimestamp(parsed.frontmatter.createdAt) }
: {}),
...(normalizeTimestamp(parsed.frontmatter.updatedAt)
? { updatedAt: normalizeTimestamp(parsed.frontmatter.updatedAt) }
: {}),
} satisfies MemoryWikiImportInsightItem,
];
})
.toSorted(compareItemsByUpdated);
const clustersByKey = new Map<string, MemoryWikiImportInsightItem[]>();
for (const item of items) {
const list = clustersByKey.get(item.topicKey) ?? [];
list.push(item);
clustersByKey.set(item.topicKey, list);
}
const clusters = [...clustersByKey.entries()]
.map(([key, clusterItems]) => {
const sortedItems = [...clusterItems].toSorted(compareItemsByUpdated);
const updatedAt = sortedItems
.map((item) => item.updatedAt ?? item.createdAt)
.find((value): value is string => typeof value === "string" && value.length > 0);
return {
key,
label: sortedItems[0]?.topicLabel ?? humanizeLabelSuffix(key),
itemCount: sortedItems.length,
highRiskCount: sortedItems.filter((item) => item.riskLevel === "high").length,
withheldCount: sortedItems.filter((item) => item.digestStatus === "withheld").length,
preferenceSignalCount: sortedItems.reduce(
(sum, item) => sum + item.preferenceSignals.length,
0,
),
...(updatedAt ? { updatedAt } : {}),
items: sortedItems,
} satisfies MemoryWikiImportInsightCluster;
})
.toSorted((left, right) => {
const leftKey = left.updatedAt ?? "";
const rightKey = right.updatedAt ?? "";
if (rightKey !== leftKey) {
return rightKey.localeCompare(leftKey);
}
if (right.itemCount !== left.itemCount) {
return right.itemCount - left.itemCount;
}
return left.label.localeCompare(right.label);
});
return {
sourceType: "chatgpt",
totalItems: items.length,
totalClusters: clusters.length,
clusters,
};
}

View File

@@ -0,0 +1,138 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedMemoryWikiConfig } from "./config.js";
export type MemoryWikiImportRunSummary = {
runId: string;
importType: string;
appliedAt: string;
exportPath: string;
sourcePath: string;
conversationCount: number;
createdCount: number;
updatedCount: number;
skippedCount: number;
status: "applied" | "rolled_back";
rolledBackAt?: string;
pagePaths: string[];
samplePaths: string[];
};
export type MemoryWikiImportRunsStatus = {
runs: MemoryWikiImportRunSummary[];
totalRuns: number;
activeRuns: number;
rolledBackRuns: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
}
function normalizeImportRunSummary(raw: unknown): MemoryWikiImportRunSummary | null {
const record = asRecord(raw);
const runId = typeof record?.runId === "string" ? record.runId.trim() : "";
const importType = typeof record?.importType === "string" ? record.importType.trim() : "";
const appliedAt = typeof record?.appliedAt === "string" ? record.appliedAt.trim() : "";
const exportPath = typeof record?.exportPath === "string" ? record.exportPath.trim() : "";
const sourcePath = typeof record?.sourcePath === "string" ? record.sourcePath.trim() : "";
if (!runId || !importType || !appliedAt || !exportPath || !sourcePath) {
return null;
}
const createdPaths = asStringArray(record.createdPaths);
const updatedPaths = Array.isArray(record.updatedPaths)
? record.updatedPaths
.map((entry) => asRecord(entry))
.map((entry) => (typeof entry?.path === "string" ? entry.path.trim() : ""))
.filter((entry): entry is string => entry.length > 0)
: [];
const pagePaths = [...new Set([...createdPaths, ...updatedPaths])];
const conversationCount =
typeof record.conversationCount === "number" && Number.isFinite(record.conversationCount)
? Math.max(0, Math.floor(record.conversationCount))
: createdPaths.length + updatedPaths.length;
const createdCount =
typeof record.createdCount === "number" && Number.isFinite(record.createdCount)
? Math.max(0, Math.floor(record.createdCount))
: createdPaths.length;
const updatedCount =
typeof record.updatedCount === "number" && Number.isFinite(record.updatedCount)
? Math.max(0, Math.floor(record.updatedCount))
: updatedPaths.length;
const skippedCount =
typeof record.skippedCount === "number" && Number.isFinite(record.skippedCount)
? Math.max(0, Math.floor(record.skippedCount))
: Math.max(0, conversationCount - createdCount - updatedCount);
const rolledBackAt =
typeof record.rolledBackAt === "string" && record.rolledBackAt.trim().length > 0
? record.rolledBackAt.trim()
: undefined;
return {
runId,
importType,
appliedAt,
exportPath,
sourcePath,
conversationCount,
createdCount,
updatedCount,
skippedCount,
status: rolledBackAt ? "rolled_back" : "applied",
...(rolledBackAt ? { rolledBackAt } : {}),
pagePaths,
samplePaths: pagePaths.slice(0, 5),
};
}
function resolveImportRunsDir(vaultRoot: string): string {
return path.join(vaultRoot, ".openclaw-wiki", "import-runs");
}
export async function listMemoryWikiImportRuns(
config: ResolvedMemoryWikiConfig,
options?: { limit?: number },
): Promise<MemoryWikiImportRunsStatus> {
const limit = Math.max(1, Math.floor(options?.limit ?? 10));
const importRunsDir = resolveImportRunsDir(config.vault.path);
const entries = await fs
.readdir(importRunsDir, { withFileTypes: true })
.catch((error: NodeJS.ErrnoException) => {
if (error?.code === "ENOENT") {
return [];
}
throw error;
});
const runs = (
await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map(async (entry) => {
const raw = await fs.readFile(path.join(importRunsDir, entry.name), "utf8");
return normalizeImportRunSummary(JSON.parse(raw) as unknown);
}),
)
)
.filter((entry): entry is MemoryWikiImportRunSummary => entry !== null)
.toSorted((left, right) => right.appliedAt.localeCompare(left.appliedAt));
return {
runs: runs.slice(0, limit),
totalRuns: runs.length,
activeRuns: runs.filter((entry) => entry.status === "applied").length,
rolledBackRuns: runs.filter((entry) => entry.status === "rolled_back").length,
};
}

View File

@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { renderWikiMarkdown } from "./markdown.js";
import { listMemoryWikiPalace } from "./memory-palace.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createVault } = createMemoryWikiTestHarness();
describe("listMemoryWikiPalace", () => {
it("groups wiki pages by kind and surfaces claims, questions, and contradictions", async () => {
const { rootDir, config } = await createVault({
prefix: "memory-wiki-palace-",
initialize: true,
});
await fs.mkdir(path.join(rootDir, "syntheses"), { recursive: true });
await fs.mkdir(path.join(rootDir, "entities"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "syntheses", "travel-system.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "synthesis",
id: "synthesis.travel.system",
title: "Travel system",
claims: [
{ text: "Mariano prefers direct receipts from airlines when possible." },
{ text: "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."],
updatedAt: "2026-04-10T12:00:00.000Z",
},
body: [
"# Travel system",
"",
"This synthesis rolls up recurring travel admin patterns from imported chats.",
"",
].join("\n"),
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "entities", "mariano.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.mariano",
title: "Mariano",
claims: [{ text: "He prefers compact, inspectable systems." }],
updatedAt: "2026-04-09T08:00:00.000Z",
},
body: ["# Mariano", "", "Primary operator profile page.", ""].join("\n"),
}),
"utf8",
);
const result = await listMemoryWikiPalace(config);
expect(result).toMatchObject({
totalItems: 2,
totalClaims: 3,
totalQuestions: 1,
totalContradictions: 1,
});
expect(result.clusters[0]).toMatchObject({
key: "synthesis",
label: "Syntheses",
itemCount: 1,
claimCount: 2,
questionCount: 1,
contradictionCount: 1,
});
expect(result.clusters[0]?.items[0]).toMatchObject({
title: "Travel system",
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: "This synthesis rolls up recurring travel admin patterns from imported chats.",
});
expect(result.clusters[1]).toMatchObject({
key: "entity",
label: "Entities",
itemCount: 1,
claimCount: 1,
});
});
});

View File

@@ -0,0 +1,148 @@
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown, type WikiPageKind } from "./markdown.js";
import { readQueryableWikiPages } from "./query.js";
const PALACE_KIND_ORDER: WikiPageKind[] = ["synthesis", "entity", "concept", "source", "report"];
const PRIMARY_PALACE_KINDS = new Set<WikiPageKind>(["synthesis", "entity", "concept"]);
const PALACE_KIND_LABELS: Record<WikiPageKind, string> = {
synthesis: "Syntheses",
entity: "Entities",
concept: "Concepts",
source: "Sources",
report: "Reports",
};
export type MemoryWikiPalaceItem = {
pagePath: string;
title: string;
kind: WikiPageKind;
id?: string;
updatedAt?: string;
sourceType?: string;
claimCount: number;
questionCount: number;
contradictionCount: number;
claims: string[];
questions: string[];
contradictions: string[];
snippet?: string;
};
export type MemoryWikiPalaceCluster = {
key: WikiPageKind;
label: string;
itemCount: number;
claimCount: number;
questionCount: number;
contradictionCount: number;
updatedAt?: string;
items: MemoryWikiPalaceItem[];
};
export type MemoryWikiPalaceStatus = {
totalItems: number;
totalClaims: number;
totalQuestions: number;
totalContradictions: number;
clusters: MemoryWikiPalaceCluster[];
};
function normalizeTimestamp(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function extractSnippet(body: string): string | undefined {
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
if (
!line ||
line.startsWith("#") ||
line.startsWith("```") ||
line.startsWith("<!--") ||
line.startsWith("- ") ||
line.startsWith("* ")
) {
continue;
}
return line;
}
return undefined;
}
function comparePalaceItems(left: MemoryWikiPalaceItem, right: MemoryWikiPalaceItem): number {
const leftKey = left.updatedAt ?? "";
const rightKey = right.updatedAt ?? "";
if (rightKey !== leftKey) {
return rightKey.localeCompare(leftKey);
}
if (right.claimCount !== left.claimCount) {
return right.claimCount - left.claimCount;
}
return left.title.localeCompare(right.title);
}
export async function listMemoryWikiPalace(
config: ResolvedMemoryWikiConfig,
): Promise<MemoryWikiPalaceStatus> {
const pages = await readQueryableWikiPages(config.vault.path);
const items = pages
.map((page) => {
const parsed = parseWikiMarkdown(page.raw);
return {
pagePath: page.relativePath,
title: page.title,
kind: page.kind,
...(page.id ? { id: page.id } : {}),
...(normalizeTimestamp(page.updatedAt)
? { updatedAt: normalizeTimestamp(page.updatedAt) }
: {}),
...(typeof page.sourceType === "string" && page.sourceType.trim().length > 0
? { sourceType: page.sourceType.trim() }
: {}),
claimCount: page.claims.length,
questionCount: page.questions.length,
contradictionCount: page.contradictions.length,
claims: page.claims.map((claim) => claim.text).slice(0, 3),
questions: page.questions.slice(0, 3),
contradictions: page.contradictions.slice(0, 3),
...(extractSnippet(parsed.body) ? { snippet: extractSnippet(parsed.body) } : {}),
} satisfies MemoryWikiPalaceItem;
})
.filter(
(item) =>
PRIMARY_PALACE_KINDS.has(item.kind) ||
item.claimCount > 0 ||
item.questionCount > 0 ||
item.contradictionCount > 0,
)
.toSorted(comparePalaceItems);
const clusters = PALACE_KIND_ORDER.map((kind) => {
const clusterItems = items.filter((item) => item.kind === kind);
if (clusterItems.length === 0) {
return null;
}
return {
key: kind,
label: PALACE_KIND_LABELS[kind],
itemCount: clusterItems.length,
claimCount: clusterItems.reduce((sum, item) => sum + item.claimCount, 0),
questionCount: clusterItems.reduce((sum, item) => sum + item.questionCount, 0),
contradictionCount: clusterItems.reduce((sum, item) => sum + item.contradictionCount, 0),
...(clusterItems[0]?.updatedAt ? { updatedAt: clusterItems[0].updatedAt } : {}),
items: clusterItems,
} satisfies MemoryWikiPalaceCluster;
}).filter((entry): entry is MemoryWikiPalaceCluster => entry !== null);
return {
totalItems: items.length,
totalClaims: items.reduce((sum, item) => sum + item.claimCount, 0),
totalQuestions: items.reduce((sum, item) => sum + item.questionCount, 0),
totalContradictions: items.reduce((sum, item) => sum + item.contradictionCount, 0),
clusters,
};
}

View File

@@ -448,6 +448,8 @@ describe("getMemoryWikiPage", () => {
expect(result?.content).toContain("line one");
expect(result?.content).toContain("line two");
expect(result?.content).not.toContain("line three");
expect(result?.totalLines).toBe(7);
expect(result?.truncated).toBe(true);
});
it("resolves compiled claim ids back to the owning page", async () => {

View File

@@ -75,6 +75,8 @@ export type WikiGetResult = {
content: string;
fromLine: number;
lineCount: number;
totalLines?: number;
truncated?: boolean;
id?: string;
sourceType?: string;
provenanceMode?: string;
@@ -709,7 +711,9 @@ export async function getMemoryWikiPage(params: {
if (page) {
const parsed = parseWikiMarkdown(page.raw);
const lines = parsed.body.split(/\r?\n/);
const totalLines = lines.length;
const slice = lines.slice(fromLine - 1, fromLine - 1 + lineCount).join("\n");
const truncated = fromLine - 1 + lineCount < totalLines;
return {
corpus: "wiki",
@@ -719,6 +723,8 @@ export async function getMemoryWikiPage(params: {
content: slice,
fromLine,
lineCount,
totalLines,
truncated,
...(page.id ? { id: page.id } : {}),
...(page.sourceType ? { sourceType: page.sourceType } : {}),
...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}),

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>
`;
}