Fix dreaming replay, repair polluted artifacts, and gate wiki tabs (#65138)

* fix(active-memory): preserve parent channel context for recall runs

* fix(active-memory): keep recall runs on the resolved channel

* fix(active-memory): prefer resolved recall channel over wrapper hints

* fix(active-memory): trust explicit recall channel hints

* fix(active-memory): rank recall channel fallbacks by trust

* Fix dreaming replay and recovery flows

* fix: prevent dreaming event loss and diary write races

* chore: add changelog entry for memory fixes

* fix: harden dreaming repair and diary writes

* fix: harden dreaming artifact archive naming
This commit is contained in:
Tak Hoffman
2026-04-12 00:25:11 -05:00
committed by GitHub
parent 5543925cd2
commit 847739d82c
45 changed files with 2016 additions and 148 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory/active-memory+dreaming: keep active-memory recall runs on the strongest resolved channel, consume managed dreaming heartbeat events exactly once, stop dreaming from re-ingesting its own narrative transcripts, and add explicit repair/dedupe recovery flows in CLI, doctor, and the Dreams UI.
- Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys.
- Doctor: warn when on-disk agent directories still exist under `~/.openclaw/agents/<id>/agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana.
- Telegram: route approval button callback queries onto a separate sequentializer lane so plugin approval clicks can resolve immediately instead of deadlocking behind the blocked agent turn. (#64979) Thanks @nk3750.

View File

@@ -1033,7 +1033,10 @@ function buildPluginDebugLine(params: {
if (fallback) {
debugParts.push(`fallback=${fallback}`);
}
if (typeof params.searchDebug?.searchMs === "number" && Number.isFinite(params.searchDebug.searchMs)) {
if (
typeof params.searchDebug?.searchMs === "number" &&
Number.isFinite(params.searchDebug.searchMs)
) {
debugParts.push(`searchMs=${Math.max(0, Math.round(params.searchDebug.searchMs))}`);
}
if (typeof params.searchDebug?.hits === "number" && Number.isFinite(params.searchDebug.hits)) {
@@ -1220,7 +1223,9 @@ function normalizeSearchDebug(value: unknown): ActiveMemorySearchDebug | undefin
: undefined;
}
function readActiveMemorySearchDebugFromRunResult(result: unknown): ActiveMemorySearchDebug | undefined {
function readActiveMemorySearchDebugFromRunResult(
result: unknown,
): ActiveMemorySearchDebug | undefined {
const record = asRecord(result);
const meta = asRecord(record?.meta);
return (

View File

@@ -113,11 +113,11 @@ function requireAutocomplete(option: CommandOption, errorMessage: string) {
if (typeof autocomplete !== "function") {
throw new Error(errorMessage);
}
return autocomplete;
return autocomplete as (interaction: unknown) => Promise<unknown>;
}
async function runAutocomplete(
autocomplete: (interaction: never) => Promise<unknown>,
autocomplete: (interaction: unknown) => Promise<unknown>,
params: {
userId: string;
username?: string;

View File

@@ -4,5 +4,9 @@ export type {
MemoryProviderStatus,
MemorySyncProgressUpdate,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
export { removeBackfillDiaryEntries, writeBackfillDiaryEntries } from "./src/dreaming-narrative.js";
export {
dedupeDreamDiaryEntries,
removeBackfillDiaryEntries,
writeBackfillDiaryEntries,
} from "./src/dreaming-narrative.js";
export { previewGroundedRemMarkdown } from "./src/rem-evidence.js";

View File

@@ -15,12 +15,17 @@ export {
} from "openclaw/plugin-sdk/memory-core-host-status";
export { checkQmdBinaryAvailability } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
export { hasConfiguredMemorySecretInput } from "openclaw/plugin-sdk/memory-core-host-secret";
export { auditDreamingArtifacts, repairDreamingArtifacts } from "./src/dreaming-repair.js";
export {
auditShortTermPromotionArtifacts,
removeGroundedShortTermCandidates,
repairShortTermPromotionArtifacts,
} from "./src/short-term-promotion.js";
export type { BuiltinMemoryEmbeddingProviderDoctorMetadata } from "./src/memory/provider-adapters.js";
export type {
DreamingArtifactsAuditSummary,
RepairDreamingArtifactsResult,
} from "./src/dreaming-repair.js";
export type {
RepairShortTermPromotionArtifactsResult,
ShortTermAuditSummary,

View File

@@ -37,6 +37,12 @@ import type {
} from "./cli.types.js";
import { removeBackfillDiaryEntries, writeBackfillDiaryEntries } from "./dreaming-narrative.js";
import { previewRemDreaming, seedHistoricalDailyMemorySignals } from "./dreaming-phases.js";
import {
auditDreamingArtifacts,
repairDreamingArtifacts,
type DreamingArtifactsAuditSummary,
type RepairDreamingArtifactsResult,
} from "./dreaming-repair.js";
import { asRecord } from "./dreaming-shared.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
import { previewGroundedRemMarkdown } from "./rem-evidence.js";
@@ -249,6 +255,35 @@ function formatRepairSummary(repair: RepairShortTermPromotionArtifactsResult): s
return actions.length > 0 ? actions.join(" · ") : "no changes";
}
function formatDreamingAuditSummary(audit: DreamingArtifactsAuditSummary): string {
const bits = [
audit.dreamsPath ? "diary present" : "diary absent",
`${audit.sessionCorpusFileCount} corpus files`,
audit.sessionIngestionExists ? "ingestion state present" : "ingestion state absent",
audit.suspiciousSessionCorpusLineCount > 0
? `${audit.suspiciousSessionCorpusLineCount} suspicious lines`
: null,
].filter(Boolean);
return bits.join(" · ");
}
function formatDreamingRepairSummary(repair: RepairDreamingArtifactsResult): string {
const actions: string[] = [];
if (repair.archivedSessionCorpus) {
actions.push("archived session corpus");
}
if (repair.archivedSessionIngestion) {
actions.push("archived ingestion state");
}
if (repair.archivedDreamsDiary) {
actions.push("archived diary");
}
if (repair.warnings.length > 0) {
actions.push(`${repair.warnings.length} warning${repair.warnings.length === 1 ? "" : "s"}`);
}
return actions.length > 0 ? actions.join(" · ") : "no changes";
}
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
if (source === "memory") {
return shortenHomeInString(
@@ -648,6 +683,8 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
scan?: MemorySourceScan;
audit?: ShortTermAuditSummary;
repair?: RepairShortTermPromotionArtifactsResult;
dreamingAudit?: DreamingArtifactsAuditSummary;
dreamingRepair?: RepairDreamingArtifactsResult;
}> = [];
for (const agentId of agentIds) {
@@ -723,7 +760,14 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
: undefined;
let audit: ShortTermAuditSummary | undefined;
let repair: RepairShortTermPromotionArtifactsResult | undefined;
let dreamingAudit: DreamingArtifactsAuditSummary | undefined;
let dreamingRepair: RepairDreamingArtifactsResult | undefined;
if (workspaceDir) {
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
if (opts.fix && dreamingAudit.issues.some((issue) => issue.fixable)) {
dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
}
if (opts.fix) {
repair = await repairShortTermPromotionArtifacts({ workspaceDir });
}
@@ -742,7 +786,17 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
: undefined,
});
}
allResults.push({ agentId, status, embeddingProbe, indexError, scan, audit, repair });
allResults.push({
agentId,
status,
embeddingProbe,
indexError,
scan,
audit,
repair,
dreamingAudit,
dreamingRepair,
});
},
});
}
@@ -762,7 +816,17 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
const label = (text: string) => muted(`${text}:`);
for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError, scan, audit, repair } = result;
const {
agentId,
status,
embeddingProbe,
indexError,
scan,
audit,
repair,
dreamingAudit,
dreamingRepair,
} = result;
const filesIndexed = status.files ?? 0;
const chunksIndexed = status.chunks ?? 0;
const totalFiles = scan?.totalFiles ?? null;
@@ -898,9 +962,29 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(`${label("QMD audit")} ${info(qmdBits.join(" · "))}`);
}
}
if (dreamingAudit) {
lines.push(
`${label("Dreaming artifacts")} ${info(formatDreamingAuditSummary(dreamingAudit))}`,
);
lines.push(
`${label("Dream corpus")} ${info(shortenHomePath(dreamingAudit.sessionCorpusDir))}`,
);
lines.push(
`${label("Dream ingestion")} ${info(shortenHomePath(dreamingAudit.sessionIngestionPath))}`,
);
if (dreamingAudit.dreamsPath) {
lines.push(`${label("Dream diary")} ${info(shortenHomePath(dreamingAudit.dreamsPath))}`);
}
}
if (repair) {
lines.push(`${label("Repair")} ${info(formatRepairSummary(repair))}`);
}
if (dreamingRepair) {
lines.push(`${label("Dream repair")} ${info(formatDreamingRepairSummary(dreamingRepair))}`);
if (dreamingRepair.archiveDir) {
lines.push(`${label("Dream archive")} ${info(shortenHomePath(dreamingRepair.archiveDir))}`);
}
}
if (status.fallback?.reason) {
lines.push(muted(status.fallback.reason));
}
@@ -924,6 +1008,17 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(` ${muted(`Fix: openclaw memory status --fix --agent ${agentId}`)}`);
}
}
if (dreamingAudit?.issues.length) {
if (!scan?.issues.length && !audit?.issues.length) {
lines.push(label("Issues"));
}
for (const issue of dreamingAudit.issues) {
lines.push(` ${issue.severity === "error" ? warn(issue.message) : muted(issue.message)}`);
}
if (!opts.fix) {
lines.push(` ${muted(`Fix: openclaw memory status --fix --agent ${agentId}`)}`);
}
}
defaultRuntime.log(lines.join("\n"));
defaultRuntime.log("");
}

View File

@@ -474,6 +474,50 @@ describe("memory cli", () => {
});
});
it("repairs contaminated dreaming artifacts during status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(
path.join(sessionCorpusDir, "2026-04-11.txt"),
[
"[main/dreaming-main.jsonl#L3] ordinary session line",
"[main/dreaming-narrative-light.jsonl#L1] Write a dream diary entry from these memory fragments:",
].join("\n"),
"utf-8",
);
await fs.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
"utf-8",
);
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "# Dream Diary\n", "utf-8");
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--fix"]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Dream repair: archived session corpus"),
);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Dream archive:"));
await expect(fs.access(sessionCorpusDir)).rejects.toMatchObject({ code: "ENOENT" });
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"# Dream Diary",
);
expect(close).toHaveBeenCalled();
});
});
it("enables verbose logging with --verbose", async () => {
const close = vi.fn(async () => {});
mockManager({

View File

@@ -5,11 +5,13 @@ import {
SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE,
} from "openclaw/plugin-sdk/error-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveGlobalMap } from "../../../src/shared/global-singleton.js";
import {
appendNarrativeEntry,
buildBackfillDiaryEntry,
buildDiaryEntry,
buildNarrativePrompt,
dedupeDreamDiaryEntries,
extractNarrativeText,
formatNarrativeDate,
formatBackfillDiaryDate,
@@ -21,9 +23,11 @@ import {
import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
const DREAMS_FILE_LOCKS_KEY = Symbol.for("openclaw.memoryCore.dreamingNarrative.fileLocks");
afterEach(() => {
vi.restoreAllMocks();
resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY).clear();
});
describe("buildNarrativePrompt", () => {
@@ -358,6 +362,145 @@ describe("appendNarrativeEntry", () => {
expect(stat.mode & 0o777).toBe(0o600);
});
it("dedupes only exact diary duplicates while keeping distinct timestamps", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-dedupe-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(
dreamsPath,
[
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
"---",
"",
"*April 11, 2026, 8:00 AM*",
"",
"The server room smelled like rain.",
"",
"---",
"",
"*April 11, 2026, 8:00 AM*",
"",
"<!-- transient comment -->",
"",
"The server room smelled like rain.",
"",
"---",
"",
"*April 11, 2026, 8:30 AM*",
"",
"The server room smelled like rain.",
"",
"<!-- openclaw:dreaming:diary:end -->",
"",
].join("\n"),
"utf-8",
);
const result = await dedupeDreamDiaryEntries({ workspaceDir });
expect(result.removed).toBe(1);
expect(result.kept).toBe(2);
const content = await fs.readFile(dreamsPath, "utf-8");
expect(content.match(/The server room smelled like rain\./g)?.length).toBe(2);
expect(content).toContain("*April 11, 2026, 8:00 AM*");
expect(content).toContain("*April 11, 2026, 8:30 AM*");
});
it("serializes append and dedupe so concurrent rewrites keep the new entry", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-dedupe-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(
dreamsPath,
[
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
"---",
"",
"*April 11, 2026, 8:00 AM*",
"",
"The server room smelled like rain.",
"",
"---",
"",
"*April 11, 2026, 8:00 AM*",
"",
"The server room smelled like rain.",
"",
"<!-- openclaw:dreaming:diary:end -->",
"",
].join("\n"),
"utf-8",
);
await Promise.all([
dedupeDreamDiaryEntries({ workspaceDir }),
appendNarrativeEntry({
workspaceDir,
narrative: "A fresh signal arrived after the cleanup started.",
nowMs: Date.parse("2026-04-11T14:30:00Z"),
timezone: "UTC",
}),
]);
const content = await fs.readFile(dreamsPath, "utf-8");
expect(content.match(/The server room smelled like rain\./g)?.length).toBe(1);
expect(content).toContain("A fresh signal arrived after the cleanup started.");
});
it("keeps dedupe a no-op when no exact duplicates exist", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-dedupe-");
await appendNarrativeEntry({
workspaceDir,
narrative: "Only one entry exists.",
nowMs: Date.parse("2026-04-11T14:00:00Z"),
timezone: "UTC",
});
const result = await dedupeDreamDiaryEntries({ workspaceDir });
expect(result.removed).toBe(0);
expect(result.kept).toBe(1);
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"Only one entry exists.",
);
});
it("does not rewrite the diary file when dedupe finds nothing to remove", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-dedupe-");
const dreamsPath = await appendNarrativeEntry({
workspaceDir,
narrative: "Only one entry exists.",
nowMs: Date.parse("2026-04-11T14:00:00Z"),
timezone: "UTC",
});
const before = await fs.stat(dreamsPath);
await new Promise((resolve) => setTimeout(resolve, 20));
const result = await dedupeDreamDiaryEntries({ workspaceDir });
const after = await fs.stat(dreamsPath);
expect(result.removed).toBe(0);
expect(after.mtimeMs).toBe(before.mtimeMs);
});
it("cleans up the per-file lock entry after diary updates finish", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-dedupe-");
const dreamsLocks = resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY);
expect(dreamsLocks.size).toBe(0);
await appendNarrativeEntry({
workspaceDir,
narrative: "Only one entry exists.",
nowMs: Date.parse("2026-04-11T14:00:00Z"),
timezone: "UTC",
});
expect(dreamsLocks.size).toBe(0);
});
it("surfaces temp cleanup failure after atomic replace error", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");

View File

@@ -7,6 +7,8 @@ import {
readErrorName,
SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE,
} from "openclaw/plugin-sdk/error-runtime";
import { createAsyncLock } from "../../../src/infra/json-files.js";
import { resolveGlobalMap } from "../../../src/shared/global-singleton.js";
// ── Types ──────────────────────────────────────────────────────────────
@@ -78,6 +80,14 @@ const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
const DREAMS_FILE_LOCKS_KEY = Symbol.for("openclaw.memoryCore.dreamingNarrative.fileLocks");
type DreamsFileLockEntry = {
withLock: ReturnType<typeof createAsyncLock>;
refs: number;
};
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
function isRequestScopedSubagentRuntimeError(err: unknown): boolean {
return (
@@ -291,6 +301,31 @@ function splitDiaryBlocks(diaryContent: string): string[] {
.filter((block) => block.length > 0);
}
function normalizeDiaryBlockFingerprint(block: string): string {
const lines = block
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let dateLine = "";
const bodyLines: string[] = [];
for (const line of lines) {
if (!dateLine && line.startsWith("*") && line.endsWith("*") && line.length > 2) {
dateLine = line.slice(1, -1).trim();
continue;
}
if (line.startsWith("<!--") || line.startsWith("#")) {
continue;
}
bodyLines.push(line);
}
const normalizedDate = dateLine.replace(/\s+/g, " ").trim();
const normalizedBody = bodyLines
.join("\n")
.replace(/[ \t]+\n/g, "\n")
.trim();
return `${normalizedDate}\n${normalizedBody}`;
}
function joinDiaryBlocks(blocks: string[]): string {
if (blocks.length === 0) {
return "";
@@ -383,6 +418,44 @@ async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promi
}
}
async function updateDreamsFile<T>(params: {
workspaceDir: string;
updater: (
existing: string,
dreamsPath: string,
) =>
| Promise<{ content: string; result: T; shouldWrite?: boolean }>
| {
content: string;
result: T;
shouldWrite?: boolean;
};
}): Promise<T> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
let lockEntry = dreamsFileLocks.get(dreamsPath);
if (!lockEntry) {
lockEntry = { withLock: createAsyncLock(), refs: 0 };
dreamsFileLocks.set(dreamsPath, lockEntry);
}
lockEntry.refs += 1;
try {
return await lockEntry.withLock(async () => {
const existing = await readDreamsFile(dreamsPath);
const { content, result, shouldWrite = true } = await params.updater(existing, dreamsPath);
if (shouldWrite) {
await writeDreamsFileAtomic(dreamsPath, content.endsWith("\n") ? content : `${content}\n`);
}
return result;
});
} finally {
lockEntry.refs -= 1;
if (lockEntry.refs <= 0 && dreamsFileLocks.get(dreamsPath) === lockEntry) {
dreamsFileLocks.delete(dreamsPath);
}
}
}
export function buildBackfillDiaryEntry(params: {
isoDay: string;
bodyLines: string[];
@@ -407,51 +480,100 @@ export async function writeBackfillDiaryEntries(params: {
}>;
timezone?: string;
}): Promise<{ dreamsPath: string; written: number; replaced: number }> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
const existing = await readDreamsFile(dreamsPath);
const stripped = stripBackfillDiaryBlocks(existing);
const startIdx = stripped.updated.indexOf(DIARY_START_MARKER);
const endIdx = stripped.updated.indexOf(DIARY_END_MARKER);
const inner =
startIdx >= 0 && endIdx > startIdx
? stripped.updated.slice(startIdx + DIARY_START_MARKER.length, endIdx)
: "";
const preservedBlocks = splitDiaryBlocks(inner);
const nextBlocks = [
...preservedBlocks,
...params.entries.map((entry) =>
buildBackfillDiaryEntry({
isoDay: entry.isoDay,
bodyLines: entry.bodyLines,
sourcePath: entry.sourcePath,
timezone: params.timezone,
}),
),
];
const updated = replaceDiaryContent(stripped.updated, joinDiaryBlocks(nextBlocks));
await writeDreamsFileAtomic(dreamsPath, updated);
return {
dreamsPath,
written: params.entries.length,
replaced: stripped.removed,
};
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, dreamsPath) => {
const stripped = stripBackfillDiaryBlocks(existing);
const startIdx = stripped.updated.indexOf(DIARY_START_MARKER);
const endIdx = stripped.updated.indexOf(DIARY_END_MARKER);
const inner =
startIdx >= 0 && endIdx > startIdx
? stripped.updated.slice(startIdx + DIARY_START_MARKER.length, endIdx)
: "";
const preservedBlocks = splitDiaryBlocks(inner);
const nextBlocks = [
...preservedBlocks,
...params.entries.map((entry) =>
buildBackfillDiaryEntry({
isoDay: entry.isoDay,
bodyLines: entry.bodyLines,
sourcePath: entry.sourcePath,
timezone: params.timezone,
}),
),
];
return {
content: replaceDiaryContent(stripped.updated, joinDiaryBlocks(nextBlocks)),
result: {
dreamsPath,
written: params.entries.length,
replaced: stripped.removed,
},
};
},
});
}
export async function removeBackfillDiaryEntries(params: {
workspaceDir: string;
}): Promise<{ dreamsPath: string; removed: number }> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
const existing = await readDreamsFile(dreamsPath);
const stripped = stripBackfillDiaryBlocks(existing);
if (stripped.removed > 0 || existing.length > 0) {
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
await writeDreamsFileAtomic(dreamsPath, stripped.updated);
}
return {
dreamsPath,
removed: stripped.removed,
};
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, dreamsPath) => {
const stripped = stripBackfillDiaryBlocks(existing);
return {
content: stripped.updated,
result: {
dreamsPath,
removed: stripped.removed,
},
shouldWrite: stripped.removed > 0 || existing.length > 0,
};
},
});
}
export async function dedupeDreamDiaryEntries(params: {
workspaceDir: string;
}): Promise<{ dreamsPath: string; removed: number; kept: number }> {
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, dreamsPath) => {
const ensured = ensureDiarySection(existing);
const startIdx = ensured.indexOf(DIARY_START_MARKER);
const endIdx = ensured.indexOf(DIARY_END_MARKER);
if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
return {
content: ensured,
result: { dreamsPath, removed: 0, kept: 0 },
shouldWrite: false,
};
}
const inner = ensured.slice(startIdx + DIARY_START_MARKER.length, endIdx);
const blocks = splitDiaryBlocks(inner);
const seen = new Set<string>();
const keptBlocks: string[] = [];
let removed = 0;
for (const block of blocks) {
const fingerprint = normalizeDiaryBlockFingerprint(block);
if (seen.has(fingerprint)) {
removed += 1;
continue;
}
seen.add(fingerprint);
keptBlocks.push(block);
}
return {
content: replaceDiaryContent(ensured, joinDiaryBlocks(keptBlocks)),
result: {
dreamsPath,
removed,
kept: keptBlocks.length,
},
shouldWrite: removed > 0,
};
},
});
}
export function buildDiaryEntry(narrative: string, dateStr: string): string {
@@ -464,49 +586,31 @@ export async function appendNarrativeEntry(params: {
nowMs: number;
timezone?: string;
}): Promise<string> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
const dateStr = formatNarrativeDate(params.nowMs, params.timezone);
const entry = buildDiaryEntry(params.narrative, dateStr);
let existing = "";
try {
existing = await fs.readFile(dreamsPath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw err;
}
}
let updated: string;
if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
// Append entry before end marker.
const endIdx = existing.lastIndexOf(DIARY_END_MARKER);
updated = existing.slice(0, endIdx) + entry + "\n" + existing.slice(endIdx);
} else if (existing.includes(DIARY_START_MARKER)) {
// Start marker without end — append entry and add end marker.
const startIdx = existing.indexOf(DIARY_START_MARKER) + DIARY_START_MARKER.length;
updated =
existing.slice(0, startIdx) +
entry +
"\n" +
DIARY_END_MARKER +
"\n" +
existing.slice(startIdx);
} else {
// No diary section yet — create one.
const diarySection = `# Dream Diary\n\n${DIARY_START_MARKER}${entry}\n${DIARY_END_MARKER}\n`;
if (existing.trim().length === 0) {
updated = diarySection;
} else {
// Prepend diary before any existing managed blocks.
updated = diarySection + "\n" + existing;
}
}
await writeDreamsFileAtomic(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`);
return dreamsPath;
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, dreamsPath) => {
let updated: string;
if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
const endIdx = existing.lastIndexOf(DIARY_END_MARKER);
updated = existing.slice(0, endIdx) + entry + "\n" + existing.slice(endIdx);
} else if (existing.includes(DIARY_START_MARKER)) {
const startIdx = existing.indexOf(DIARY_START_MARKER) + DIARY_START_MARKER.length;
updated =
existing.slice(0, startIdx) +
entry +
"\n" +
DIARY_END_MARKER +
"\n" +
existing.slice(startIdx);
} else {
const diarySection = `# Dream Diary\n\n${DIARY_START_MARKER}${entry}\n${DIARY_END_MARKER}\n`;
updated = existing.trim().length === 0 ? diarySection : `${diarySection}\n${existing}`;
}
return { content: updated, result: dreamsPath };
},
});
}
// ── Orchestrator ───────────────────────────────────────────────────────

View File

@@ -568,6 +568,116 @@ describe("memory-core dreaming phases", () => {
expect(corpus).toContain("OPENAI_API_KEY=sk-123…cdef");
});
it("skips dreaming-generated narrative transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
await fs.writeFile(
transcriptPath,
[
JSON.stringify({
type: "custom",
customType: "openclaw:bootstrap-context:full",
data: {
runId: "dreaming-narrative-light-1775894400455",
sessionId: "dream-session-1",
},
}),
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-05T18:01:00.000Z",
content: [
{ type: "text", text: "Write a dream diary entry from these memory fragments." },
],
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-05T18:02:00.000Z",
content: [{ type: "text", text: "I drift through the same archive again." }],
},
}),
].join("\n") + "\n",
"utf-8",
);
const mtime = new Date("2026-04-05T18:05:00.000Z");
await fs.utimes(transcriptPath, mtime, mtime);
const { beforeAgentReply } = createHarness(
{
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", workspace: workspaceDir }],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
},
},
},
},
},
},
},
workspaceDir,
);
try {
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
}
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt")),
).rejects.toMatchObject({ code: "ENOENT" });
const sessionIngestion = JSON.parse(
await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
"utf-8",
),
) as {
files: Record<
string,
{
lineCount: number;
lastContentLine: number;
contentHash: string;
}
>;
};
expect(Object.keys(sessionIngestion.files)).toHaveLength(1);
expect(Object.values(sessionIngestion.files)).toEqual([
expect.objectContaining({
lineCount: 2,
lastContentLine: 2,
contentHash: expect.any(String),
}),
]);
});
it("dedupes reset/deleted session archives instead of double-ingesting", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");

View File

@@ -758,6 +758,26 @@ async function collectSessionIngestionBatches(params: {
if (!entry) {
continue;
}
if (entry.generatedByDreamingNarrative) {
nextFiles[stateKey] = {
mtimeMs: fingerprint.mtimeMs,
size: fingerprint.size,
contentHash: entry.hash.trim(),
lineCount: entry.lineMap.length,
lastContentLine: entry.lineMap.length,
};
if (
!previous ||
previous.mtimeMs !== fingerprint.mtimeMs ||
previous.size !== fingerprint.size ||
previous.contentHash !== entry.hash.trim() ||
previous.lineCount !== entry.lineMap.length ||
previous.lastContentLine !== entry.lineMap.length
) {
changed = true;
}
continue;
}
const contentHash = entry.hash.trim();
if (
previous &&

View File

@@ -0,0 +1,128 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { auditDreamingArtifacts, repairDreamingArtifacts } from "./dreaming-repair.js";
const tempDirs: string[] = [];
async function createWorkspace(): Promise<string> {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "dreaming-repair-test-"));
tempDirs.push(workspaceDir);
await fs.mkdir(path.join(workspaceDir, "memory", ".dreams"), { recursive: true });
return workspaceDir;
}
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await fs.rm(dir, { recursive: true, force: true });
}
}
});
describe("dreaming artifact repair", () => {
it("detects self-ingested dreaming corpus lines", async () => {
const workspaceDir = await createWorkspace();
await fs
.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-11.txt"),
[
"[main/dreaming-main.jsonl#L4] regular session text",
"[main/dreaming-narrative-light.jsonl#L1] Write a dream diary entry from these memory fragments:",
].join("\n"),
"utf-8",
)
.catch(async () => {
await fs.mkdir(path.join(workspaceDir, "memory", ".dreams", "session-corpus"), {
recursive: true,
});
await fs.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-11.txt"),
[
"[main/dreaming-main.jsonl#L4] regular session text",
"[main/dreaming-narrative-light.jsonl#L1] Write a dream diary entry from these memory fragments:",
].join("\n"),
"utf-8",
);
});
const audit = await auditDreamingArtifacts({ workspaceDir });
expect(audit.sessionCorpusFileCount).toBe(1);
expect(audit.suspiciousSessionCorpusFileCount).toBe(1);
expect(audit.suspiciousSessionCorpusLineCount).toBe(1);
expect(audit.issues).toEqual([
expect.objectContaining({
code: "dreaming-session-corpus-self-ingested",
fixable: true,
}),
]);
});
it("does not flag ordinary transcript text that merely mentions dreaming-narrative", async () => {
const workspaceDir = await createWorkspace();
await fs.mkdir(path.join(workspaceDir, "memory", ".dreams", "session-corpus"), {
recursive: true,
});
await fs.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-11.txt"),
[
"[main/chat.jsonl#L4] regular session text",
"[main/chat.jsonl#L5] We should inspect the dreaming-narrative session behavior tomorrow.",
].join("\n"),
"utf-8",
);
const audit = await auditDreamingArtifacts({ workspaceDir });
expect(audit.suspiciousSessionCorpusFileCount).toBe(0);
expect(audit.suspiciousSessionCorpusLineCount).toBe(0);
expect(audit.issues).toEqual([]);
});
it("rejects relative workspace paths during audit and repair", async () => {
await expect(auditDreamingArtifacts({ workspaceDir: "relative/workspace" })).rejects.toThrow(
"workspaceDir must be an absolute path",
);
await expect(repairDreamingArtifacts({ workspaceDir: "relative/workspace" })).rejects.toThrow(
"workspaceDir must be an absolute path",
);
});
it("archives derived dreaming artifacts without touching the diary by default", async () => {
const workspaceDir = await createWorkspace();
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
await fs.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
"utf-8",
);
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Dream Diary\n", "utf-8");
const repair = await repairDreamingArtifacts({
workspaceDir,
now: new Date("2026-04-11T21:30:00.000Z"),
});
expect(repair.changed).toBe(true);
expect(repair.archivedSessionCorpus).toBe(true);
expect(repair.archivedSessionIngestion).toBe(true);
expect(repair.archivedDreamsDiary).toBe(false);
expect(repair.archiveDir).toBe(
path.join(workspaceDir, ".openclaw-repair", "dreaming", "2026-04-11T21-30-00-000Z"),
);
await expect(fs.access(sessionCorpusDir)).rejects.toMatchObject({ code: "ENOENT" });
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary");
const archivedEntries = await fs.readdir(repair.archiveDir!);
expect(archivedEntries.some((entry) => entry.startsWith("session-corpus."))).toBe(true);
expect(archivedEntries.some((entry) => entry.startsWith("session-ingestion.json."))).toBe(true);
});
});

View File

@@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
export type DreamingArtifactsAuditIssue = {
severity: "warn" | "error";
code:
| "dreaming-session-corpus-unreadable"
| "dreaming-session-corpus-self-ingested"
| "dreaming-session-ingestion-unreadable"
| "dreaming-diary-unreadable";
message: string;
fixable: boolean;
};
export type DreamingArtifactsAuditSummary = {
dreamsPath?: string;
sessionCorpusDir: string;
sessionCorpusFileCount: number;
suspiciousSessionCorpusFileCount: number;
suspiciousSessionCorpusLineCount: number;
sessionIngestionPath: string;
sessionIngestionExists: boolean;
issues: DreamingArtifactsAuditIssue[];
};
export type RepairDreamingArtifactsResult = {
changed: boolean;
archiveDir?: string;
archivedDreamsDiary: boolean;
archivedSessionCorpus: boolean;
archivedSessionIngestion: boolean;
archivedPaths: string[];
warnings: string[];
};
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
const SESSION_CORPUS_RELATIVE_DIR = path.join("memory", ".dreams", "session-corpus");
const SESSION_INGESTION_RELATIVE_PATH = path.join("memory", ".dreams", "session-ingestion.json");
const REPAIR_ARCHIVE_RELATIVE_DIR = path.join(".openclaw-repair", "dreaming");
const DREAMING_NARRATIVE_RUN_PREFIX = "dreaming-narrative-";
const DREAMING_NARRATIVE_PROMPT_PREFIX = "Write a dream diary entry from these memory fragments";
function requireAbsoluteWorkspaceDir(rawWorkspaceDir: string): string {
const trimmed = rawWorkspaceDir.trim();
if (!trimmed) {
throw new Error("workspaceDir is required");
}
if (!path.isAbsolute(trimmed)) {
throw new Error("workspaceDir must be an absolute path");
}
return path.resolve(trimmed);
}
async function resolveExistingDreamsPath(workspaceDir: string): Promise<string | undefined> {
for (const fileName of DREAMS_FILENAMES) {
const candidate = path.join(workspaceDir, fileName);
try {
await fs.access(candidate);
return candidate;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
return undefined;
}
async function listSessionCorpusFiles(sessionCorpusDir: string): Promise<string[]> {
const entries = await fs.readdir(sessionCorpusDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".txt"))
.map((entry) => path.join(sessionCorpusDir, entry.name))
.toSorted();
}
function isSuspiciousSessionCorpusLine(line: string): boolean {
return (
line.includes(DREAMING_NARRATIVE_PROMPT_PREFIX) &&
(line.includes(DREAMING_NARRATIVE_RUN_PREFIX) || line.includes("dreaming-narrative-"))
);
}
function buildArchiveTimestamp(now: Date): string {
return now.toISOString().replace(/[:.]/g, "-");
}
async function ensureArchivablePath(targetPath: string): Promise<"file" | "dir" | null> {
const stat = await fs.lstat(targetPath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
return null;
}
throw err;
});
if (!stat) {
return null;
}
if (stat.isSymbolicLink()) {
throw new Error(`Refusing to archive symlinked path: ${targetPath}`);
}
if (stat.isDirectory()) {
return "dir";
}
if (stat.isFile()) {
return "file";
}
throw new Error(`Refusing to archive non-file artifact: ${targetPath}`);
}
async function moveToArchive(params: {
targetPath: string;
archiveDir: string;
}): Promise<string | null> {
const kind = await ensureArchivablePath(params.targetPath);
if (!kind) {
return null;
}
await fs.mkdir(params.archiveDir, { recursive: true });
const baseName = path.basename(params.targetPath);
const destination = path.join(params.archiveDir, `${baseName}.${randomUUID()}`);
await fs.rename(params.targetPath, destination);
return destination;
}
export async function auditDreamingArtifacts(params: {
workspaceDir: string;
}): Promise<DreamingArtifactsAuditSummary> {
const workspaceDir = requireAbsoluteWorkspaceDir(params.workspaceDir);
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
const sessionCorpusDir = path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR);
const sessionIngestionPath = path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
const issues: DreamingArtifactsAuditIssue[] = [];
let sessionCorpusFileCount = 0;
let suspiciousSessionCorpusFileCount = 0;
let suspiciousSessionCorpusLineCount = 0;
let sessionIngestionExists = false;
if (dreamsPath) {
try {
await fs.access(dreamsPath);
} catch (err) {
issues.push({
severity: "error",
code: "dreaming-diary-unreadable",
message: `Dream diary could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
fixable: false,
});
}
}
try {
const corpusFiles = await listSessionCorpusFiles(sessionCorpusDir);
sessionCorpusFileCount = corpusFiles.length;
for (const corpusFile of corpusFiles) {
const content = await fs.readFile(corpusFile, "utf-8");
const suspiciousLines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && isSuspiciousSessionCorpusLine(line));
if (suspiciousLines.length > 0) {
suspiciousSessionCorpusFileCount += 1;
suspiciousSessionCorpusLineCount += suspiciousLines.length;
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
issues.push({
severity: "error",
code: "dreaming-session-corpus-unreadable",
message: `Dreaming session corpus could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
fixable: false,
});
}
}
try {
await fs.access(sessionIngestionPath);
sessionIngestionExists = true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
issues.push({
severity: "error",
code: "dreaming-session-ingestion-unreadable",
message: `Dreaming session-ingestion state could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
fixable: false,
});
}
}
if (suspiciousSessionCorpusLineCount > 0) {
issues.push({
severity: "warn",
code: "dreaming-session-corpus-self-ingested",
message: `Dreaming session corpus appears to contain self-ingested narrative content (${suspiciousSessionCorpusLineCount} suspicious line${suspiciousSessionCorpusLineCount === 1 ? "" : "s"}).`,
fixable: true,
});
}
return {
...(dreamsPath ? { dreamsPath } : {}),
sessionCorpusDir,
sessionCorpusFileCount,
suspiciousSessionCorpusFileCount,
suspiciousSessionCorpusLineCount,
sessionIngestionPath,
sessionIngestionExists,
issues,
};
}
export async function repairDreamingArtifacts(params: {
workspaceDir: string;
archiveDiary?: boolean;
now?: Date;
}): Promise<RepairDreamingArtifactsResult> {
const workspaceDir = requireAbsoluteWorkspaceDir(params.workspaceDir);
const warnings: string[] = [];
const archivedPaths: string[] = [];
let archiveDir: string | undefined;
let archivedDreamsDiary = false;
let archivedSessionCorpus = false;
let archivedSessionIngestion = false;
const ensureArchiveDir = () => {
archiveDir ??= path.join(
workspaceDir,
REPAIR_ARCHIVE_RELATIVE_DIR,
buildArchiveTimestamp(params.now ?? new Date()),
);
return archiveDir;
};
const archivePathIfPresent = async (targetPath: string): Promise<string | null> => {
try {
return await moveToArchive({ targetPath, archiveDir: ensureArchiveDir() });
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
return null;
}
};
const sessionCorpusDestination = await archivePathIfPresent(
path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
);
if (sessionCorpusDestination) {
archivedSessionCorpus = true;
archivedPaths.push(sessionCorpusDestination);
}
const sessionIngestionDestination = await archivePathIfPresent(
path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH),
);
if (sessionIngestionDestination) {
archivedSessionIngestion = true;
archivedPaths.push(sessionIngestionDestination);
}
if (params.archiveDiary) {
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
if (dreamsPath) {
const dreamsDestination = await archivePathIfPresent(dreamsPath);
if (dreamsDestination) {
archivedDreamsDiary = true;
archivedPaths.push(dreamsDestination);
}
}
}
const changed = archivedDreamsDiary || archivedSessionCorpus || archivedSessionIngestion;
return {
changed,
...(archiveDir ? { archiveDir } : {}),
archivedDreamsDiary,
archivedSessionCorpus,
archivedSessionIngestion,
archivedPaths,
warnings,
};
}

View File

@@ -1,5 +1,5 @@
import { vi } from "vitest";
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { vi } from "vitest";
export type SearchImpl = (opts?: {
maxResults?: number;

View File

@@ -74,7 +74,9 @@ function queueShortTermRecallTracking(params: {
});
}
function normalizeActiveMemoryQmdSearchMode(value: unknown): "inherit" | "search" | "vsearch" | "query" {
function normalizeActiveMemoryQmdSearchMode(
value: unknown,
): "inherit" | "search" | "vsearch" | "query" {
return value === "inherit" || value === "search" || value === "vsearch" || value === "query"
? value
: "search";
@@ -97,7 +99,9 @@ function resolveActiveMemoryQmdSearchModeOverride(
? (entry as { config?: unknown })
: undefined;
const pluginConfig =
entryRecord?.config && typeof entryRecord.config === "object" && !Array.isArray(entryRecord.config)
entryRecord?.config &&
typeof entryRecord.config === "object" &&
!Array.isArray(entryRecord.config)
? (entryRecord.config as { qmd?: { searchMode?: unknown } })
: undefined;
const searchMode = normalizeActiveMemoryQmdSearchMode(pluginConfig?.qmd?.searchMode);
@@ -271,7 +275,10 @@ export function createMemorySearchTool(options: {
searchDebug = {
backend: status.backend,
configuredMode: latestDebug?.configuredMode,
effectiveMode: status.backend === "qmd" ? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode) : "n/a",
effectiveMode:
status.backend === "qmd"
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
: "n/a",
fallback: latestDebug?.fallback,
searchMs: Math.max(0, Date.now() - searchStartedAt),
hits: rawResults.length,

View File

@@ -1,3 +1,4 @@
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/infra-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalGatewayRuntimeHoisted = vi.hoisted(() => ({
@@ -12,7 +13,7 @@ vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
describe("resolveTelegramExecApproval", () => {
async function invokeResolver(params: {
approvalId: string;
decision: string;
decision: ExecApprovalReplyDecision;
senderId: string;
allowPluginFallback?: boolean;
}) {
@@ -27,7 +28,7 @@ describe("resolveTelegramExecApproval", () => {
function expectApprovalGatewayCall(params: {
approvalId: string;
decision: string;
decision: ExecApprovalReplyDecision;
senderId: string;
allowPluginFallback?: boolean;
}) {

View File

@@ -17,8 +17,32 @@ export type SessionFileEntry = {
content: string;
/** Maps each content line (0-indexed) to its 1-indexed JSONL source line. */
lineMap: number[];
/** True when this transcript belongs to an internal dreaming narrative run. */
generatedByDreamingNarrative?: boolean;
};
function isDreamingNarrativeBootstrapRecord(record: unknown): boolean {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return false;
}
const candidate = record as {
type?: unknown;
customType?: unknown;
data?: unknown;
};
if (
candidate.type !== "custom" ||
candidate.customType !== "openclaw:bootstrap-context:full" ||
!candidate.data ||
typeof candidate.data !== "object" ||
Array.isArray(candidate.data)
) {
return false;
}
const runId = (candidate.data as { runId?: unknown }).runId;
return typeof runId === "string" && runId.startsWith("dreaming-narrative-");
}
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
const dir = resolveSessionTranscriptsDirForAgent(agentId);
try {
@@ -79,6 +103,7 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
const lines = raw.split("\n");
const collected: string[] = [];
const lineMap: number[] = [];
let generatedByDreamingNarrative = false;
for (let jsonlIdx = 0; jsonlIdx < lines.length; jsonlIdx++) {
const line = lines[jsonlIdx];
if (!line.trim()) {
@@ -90,6 +115,9 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
} catch {
continue;
}
if (!generatedByDreamingNarrative && isDreamingNarrativeBootstrapRecord(record)) {
generatedByDreamingNarrative = true;
}
if (
!record ||
typeof record !== "object" ||
@@ -124,6 +152,7 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
hash: hashText(content + "\n" + lineMap.join(",")),
content,
lineMap,
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
};
} catch (err) {
log.debug(`Failed reading session file ${absPath}: ${String(err)}`);

View File

@@ -82,8 +82,13 @@ function pruneWithOversizedAssistantThinking(params: {
function buildToolTrimSettings() {
return {
mode: DEFAULT_CONTEXT_PRUNING_SETTINGS.mode,
ttlMs: DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs,
keepLastAssistants: 1,
softTrimRatio: 0.5,
hardClearRatio: DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClearRatio,
minPrunableToolChars: DEFAULT_CONTEXT_PRUNING_SETTINGS.minPrunableToolChars,
tools: DEFAULT_CONTEXT_PRUNING_SETTINGS.tools,
softTrim: { maxChars: 200, headChars: 100, tailChars: 50 },
hardClear: { ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, enabled: false },
};

View File

@@ -44,8 +44,8 @@ import { buildReplyPromptBodies } from "./prompt-prelude.js";
import { resolveActiveRunQueueAction } from "./queue-policy.js";
import { resolveQueueSettings } from "./queue/settings-runtime.js";
import { buildBareSessionResetPrompt } from "./session-reset-prompt.js";
import { buildSessionStartupContextPrelude, shouldApplyStartupContext } from "./startup-context.js";
import { drainFormattedSystemEvents } from "./session-system-events.js";
import { buildSessionStartupContextPrelude, shouldApplyStartupContext } from "./startup-context.js";
import { resolveTypingMode } from "./typing-mode.js";
import { resolveRunTypingPolicy } from "./typing-policy.js";
import type { TypingController } from "./typing.js";
@@ -290,7 +290,8 @@ export async function runPreparedReply(
const baseBodyTrimmedRaw = baseBody.trim();
const normalizedCommandBody = command.commandBodyNormalized.trim();
const isWholeMessageCommand =
normalizedCommandBody === rawBodyTrimmed || normalizedCommandBody === rawBodyTrimmed.toLowerCase();
normalizedCommandBody === rawBodyTrimmed ||
normalizedCommandBody === rawBodyTrimmed.toLowerCase();
const isResetOrNewCommand = /^\/(new|reset)(?:\s|$)/.test(normalizedCommandBody);
if (
allowTextCommands &&
@@ -306,13 +307,13 @@ export async function runPreparedReply(
isNewSession &&
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
const startupAction = /^\/reset(?:\s|$)/.test(normalizedCommandBody) ? "reset" : "new";
const startupContextPrelude = isBareSessionReset &&
shouldApplyStartupContext({ cfg, action: startupAction })
? await buildSessionStartupContextPrelude({
workspaceDir,
cfg,
})
: null;
const startupContextPrelude =
isBareSessionReset && shouldApplyStartupContext({ cfg, action: startupAction })
? await buildSessionStartupContextPrelude({
workspaceDir,
cfg,
})
: null;
const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody;
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const inboundUserContext = buildInboundUserContextPrefix(

View File

@@ -16,7 +16,9 @@ type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn;
const checkQmdBinaryAvailability = vi.hoisted(() =>
vi.fn<CheckQmdBinaryAvailability>(async () => ({ available: true })),
);
const auditDreamingArtifacts = vi.hoisted(() => vi.fn());
const auditShortTermPromotionArtifacts = vi.hoisted(() => vi.fn());
const repairDreamingArtifacts = vi.hoisted(() => vi.fn());
const repairShortTermPromotionArtifacts = vi.hoisted(() => vi.fn());
vi.mock("../terminal/note.js", () => ({
@@ -47,7 +49,9 @@ vi.mock("../memory-host-sdk/engine-qmd.js", () => ({
}));
vi.mock("../plugin-sdk/memory-core-engine-runtime.js", () => ({
auditDreamingArtifacts,
auditShortTermPromotionArtifacts,
repairDreamingArtifacts,
repairShortTermPromotionArtifacts,
getBuiltinMemoryEmbeddingProviderDoctorMetadata: vi.fn((provider: string) => {
if (provider === "gemini") {
@@ -123,6 +127,25 @@ describe("noteMemorySearchHealth", () => {
invalidEntryCount: 0,
issues: [],
});
auditDreamingArtifacts.mockReset();
auditDreamingArtifacts.mockResolvedValue({
sessionCorpusDir: "/tmp/agent-default/workspace/memory/.dreams/session-corpus",
sessionCorpusFileCount: 0,
suspiciousSessionCorpusFileCount: 0,
suspiciousSessionCorpusLineCount: 0,
sessionIngestionPath: "/tmp/agent-default/workspace/memory/.dreams/session-ingestion.json",
sessionIngestionExists: false,
issues: [],
});
repairDreamingArtifacts.mockReset();
repairDreamingArtifacts.mockResolvedValue({
changed: false,
archivedDreamsDiary: false,
archivedSessionCorpus: false,
archivedSessionIngestion: false,
archivedPaths: [],
warnings: [],
});
repairShortTermPromotionArtifacts.mockReset();
repairShortTermPromotionArtifacts.mockResolvedValue({
changed: false,
@@ -441,6 +464,25 @@ describe("memory recall doctor integration", () => {
invalidEntryCount: 0,
issues: [],
});
auditDreamingArtifacts.mockReset();
auditDreamingArtifacts.mockResolvedValue({
sessionCorpusDir: "/tmp/agent-default/workspace/memory/.dreams/session-corpus",
sessionCorpusFileCount: 0,
suspiciousSessionCorpusFileCount: 0,
suspiciousSessionCorpusLineCount: 0,
sessionIngestionPath: "/tmp/agent-default/workspace/memory/.dreams/session-ingestion.json",
sessionIngestionExists: false,
issues: [],
});
repairDreamingArtifacts.mockReset();
repairDreamingArtifacts.mockResolvedValue({
changed: false,
archivedDreamsDiary: false,
archivedSessionCorpus: false,
archivedSessionIngestion: false,
archivedPaths: [],
warnings: [],
});
repairShortTermPromotionArtifacts.mockReset();
repairShortTermPromotionArtifacts.mockResolvedValue({
changed: false,
@@ -548,6 +590,47 @@ describe("memory recall doctor integration", () => {
expect(message).toContain("rewrote recall store");
expect(message).toContain("removed stale promotion lock");
});
it("runs dreaming artifact repair during doctor --fix", async () => {
auditDreamingArtifacts.mockResolvedValueOnce({
sessionCorpusDir: "/tmp/agent-default/workspace/memory/.dreams/session-corpus",
sessionCorpusFileCount: 2,
suspiciousSessionCorpusFileCount: 1,
suspiciousSessionCorpusLineCount: 3,
sessionIngestionPath: "/tmp/agent-default/workspace/memory/.dreams/session-ingestion.json",
sessionIngestionExists: true,
issues: [
{
severity: "warn",
code: "dreaming-session-corpus-self-ingested",
message:
"Dreaming session corpus appears to contain self-ingested narrative content (3 suspicious lines).",
fixable: true,
},
],
});
repairDreamingArtifacts.mockResolvedValueOnce({
changed: true,
archiveDir: "/tmp/agent-default/workspace/.openclaw-repair/dreaming/2026-04-11T21-35-00-000Z",
archivedDreamsDiary: false,
archivedSessionCorpus: true,
archivedSessionIngestion: true,
archivedPaths: [],
warnings: [],
});
const prompter = createPrompter();
await maybeRepairMemoryRecallHealth({ cfg, prompter });
expect(prompter.confirmRuntimeRepair).toHaveBeenCalled();
expect(repairDreamingArtifacts).toHaveBeenCalledWith({
workspaceDir: "/tmp/agent-default/workspace",
});
const message = String(note.mock.calls.at(-1)?.[0] ?? "");
expect(message).toContain("Dreaming artifacts repaired:");
expect(message).toContain("archived session corpus");
expect(message).toContain("archived session-ingestion state");
});
});
describe("detectLegacyWorkspaceDirs", () => {

View File

@@ -13,10 +13,13 @@ import { DEFAULT_LOCAL_MODEL } from "../memory-host-sdk/engine-embeddings.js";
import { checkQmdBinaryAvailability } from "../memory-host-sdk/engine-qmd.js";
import { hasConfiguredMemorySecretInput } from "../memory-host-sdk/secret.js";
import {
auditDreamingArtifacts,
auditShortTermPromotionArtifacts,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
repairDreamingArtifacts,
repairShortTermPromotionArtifacts,
type DreamingArtifactsAuditSummary,
type ShortTermAuditSummary,
} from "../plugin-sdk/memory-core-engine-runtime.js";
import {
@@ -88,6 +91,22 @@ function buildMemoryRecallIssueNote(audit: ShortTermAuditSummary): string | null
].join("\n");
}
function buildDreamingArtifactIssueNote(audit: DreamingArtifactsAuditSummary): string | null {
if (audit.issues.length === 0) {
return null;
}
const issueLines = audit.issues.map((issue) => `- ${issue.message}`);
const hasFixableIssue = audit.issues.some((issue) => issue.fixable);
return [
"Dreaming artifacts need attention:",
...issueLines,
`Dream corpus: ${audit.sessionCorpusDir}`,
hasFixableIssue
? `Fix: ${formatCliCommand("openclaw doctor --fix")} or ${formatCliCommand("openclaw memory status --fix")}`
: `Verify: ${formatCliCommand("openclaw memory status --deep")}`,
].join("\n");
}
export async function noteMemoryRecallHealth(cfg: OpenClawConfig): Promise<void> {
try {
const context = await resolveRuntimeMemoryAuditContext(cfg);
@@ -109,6 +128,11 @@ export async function noteMemoryRecallHealth(cfg: OpenClawConfig): Promise<void>
if (message) {
note(message, "Memory search");
}
const dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
const dreamingMessage = buildDreamingArtifactIssueNote(dreamingAudit);
if (dreamingMessage) {
note(dreamingMessage, "Memory search");
}
} catch (err) {
note(`Memory recall audit could not be completed: ${formatErrorMessage(err)}`, "Memory search");
}
@@ -134,33 +158,57 @@ export async function maybeRepairMemoryRecallHealth(params: {
}
: undefined,
});
const hasFixableIssue = audit.issues.some((issue) => issue.fixable);
if (!hasFixableIssue) {
const hasFixableRecallIssue = audit.issues.some((issue) => issue.fixable);
if (hasFixableRecallIssue) {
const approved = await params.prompter.confirmRuntimeRepair({
message: "Normalize memory recall artifacts and remove stale promotion locks?",
initialValue: true,
});
if (approved) {
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (repair.changed) {
const lines = [
"Memory recall artifacts repaired:",
repair.rewroteStore
? `- rewrote recall store${repair.removedInvalidEntries > 0 ? ` (-${repair.removedInvalidEntries} invalid entries)` : ""}`
: null,
repair.removedStaleLock ? "- removed stale promotion lock" : null,
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
].filter(Boolean);
note(lines.join("\n"), "Doctor changes");
}
}
}
const dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
const hasFixableDreamingIssue = dreamingAudit.issues.some((issue) => issue.fixable);
if (!hasFixableDreamingIssue) {
return;
}
const approved = await params.prompter.confirmRuntimeRepair({
message: "Normalize memory recall artifacts and remove stale promotion locks?",
const approvedDreamingRepair = await params.prompter.confirmRuntimeRepair({
message: "Archive contaminated dreaming artifacts and reset derived dream corpus state?",
initialValue: true,
});
if (!approved) {
if (!approvedDreamingRepair) {
return;
}
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (!repair.changed) {
const dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
if (!dreamingRepair.changed) {
return;
}
const lines = [
"Memory recall artifacts repaired:",
repair.rewroteStore
? `- rewrote recall store${repair.removedInvalidEntries > 0 ? ` (-${repair.removedInvalidEntries} invalid entries)` : ""}`
: null,
repair.removedStaleLock ? "- removed stale promotion lock" : null,
"Dreaming artifacts repaired:",
dreamingRepair.archivedSessionCorpus ? "- archived session corpus" : null,
dreamingRepair.archivedSessionIngestion ? "- archived session-ingestion state" : null,
dreamingRepair.archivedDreamsDiary ? "- archived dream diary" : null,
dreamingRepair.archiveDir ? `- archive dir: ${dreamingRepair.archiveDir}` : null,
...dreamingRepair.warnings.map((warning) => `- warning: ${warning}`),
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
].filter(Boolean);
note(lines.join("\n"), "Doctor changes");
} catch (err) {
note(
`Memory recall repair could not be completed: ${formatErrorMessage(err)}`,
`Memory artifact repair could not be completed: ${formatErrorMessage(err)}`,
"Memory search",
);
}

View File

@@ -851,7 +851,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.startupContext":
'Runtime-owned first-turn prelude for bare "/new" and "/reset". Use this to control whether recent daily memory files are preloaded into the first prompt instead of asking the model to decide what to read.',
"agents.defaults.startupContext.enabled":
'Enable the startup-context prelude for bare session resets (default: true). Disable this to fall back to prompt-only behavior with no runtime-loaded daily memory.',
"Enable the startup-context prelude for bare session resets (default: true). Disable this to fall back to prompt-only behavior with no runtime-loaded daily memory.",
"agents.defaults.startupContext.applyOn":
'Chooses which bare reset commands get startup context: include "new", "reset", or both (default: ["new","reset"]).',
"agents.defaults.startupContext.dailyMemoryDays":

View File

@@ -61,7 +61,12 @@ export const AgentDefaultsSchema = z
enabled: z.boolean().optional(),
applyOn: z.array(z.union([z.literal("new"), z.literal("reset")])).optional(),
dailyMemoryDays: z.number().int().min(1).max(14).optional(),
maxFileBytes: z.number().int().min(1).max(64 * 1024).optional(),
maxFileBytes: z
.number()
.int()
.min(1)
.max(64 * 1024)
.optional(),
maxFileChars: z.number().int().min(1).max(10_000).optional(),
maxTotalChars: z.number().int().min(1).max(50_000).optional(),
})

View File

@@ -1,6 +1,8 @@
export {
dedupeDreamDiaryEntries,
removeBackfillDiaryEntries,
previewGroundedRemMarkdown,
repairDreamingArtifacts,
writeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
} from "../../plugin-sdk/memory-core-bundled-runtime.js";

View File

@@ -16,9 +16,11 @@ const resolveMemorySearchConfig = vi.hoisted(() =>
);
const getMemorySearchManager = vi.hoisted(() => vi.fn());
const previewGroundedRemMarkdown = vi.hoisted(() => vi.fn());
const dedupeDreamDiaryEntries = vi.hoisted(() => vi.fn());
const writeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeGroundedShortTermCandidates = vi.hoisted(() => vi.fn());
const repairDreamingArtifacts = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", () => ({
loadConfig,
@@ -38,10 +40,12 @@ vi.mock("../../plugins/memory-runtime.js", () => ({
}));
vi.mock("./doctor.memory-core-runtime.js", () => ({
dedupeDreamDiaryEntries,
previewGroundedRemMarkdown,
writeBackfillDiaryEntries,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
repairDreamingArtifacts,
}));
import { doctorHandlers } from "./doctor.js";
@@ -113,6 +117,28 @@ const invokeDoctorMemoryResetGroundedShortTerm = async (respond: ReturnType<type
});
};
const invokeDoctorMemoryRepairDreamingArtifacts = async (respond: ReturnType<typeof vi.fn>) => {
await doctorHandlers["doctor.memory.repairDreamingArtifacts"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
};
const invokeDoctorMemoryDedupeDreamDiary = async (respond: ReturnType<typeof vi.fn>) => {
await doctorHandlers["doctor.memory.dedupeDreamDiary"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
};
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
expect(respond).toHaveBeenCalledWith(
true,
@@ -135,9 +161,11 @@ describe("doctor.memory.status", () => {
resolveMemorySearchConfig.mockReset().mockReturnValue({ enabled: true });
getMemorySearchManager.mockReset();
previewGroundedRemMarkdown.mockReset();
dedupeDreamDiaryEntries.mockReset();
writeBackfillDiaryEntries.mockReset();
removeBackfillDiaryEntries.mockReset();
removeGroundedShortTermCandidates.mockReset();
repairDreamingArtifacts.mockReset();
});
it("returns gateway embedding probe status for the default agent", async () => {
@@ -742,6 +770,69 @@ describe("doctor.memory dream actions", () => {
undefined,
);
});
it("repairs contaminated dreaming artifacts for control-ui callers", async () => {
resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw");
repairDreamingArtifacts.mockResolvedValue({
changed: true,
archiveDir: "/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-00-00-000Z",
archivedDreamsDiary: false,
archivedSessionCorpus: true,
archivedSessionIngestion: true,
archivedPaths: [],
warnings: [],
});
const respond = vi.fn();
await invokeDoctorMemoryRepairDreamingArtifacts(respond);
expect(repairDreamingArtifacts).toHaveBeenCalledWith({
workspaceDir: "/tmp/openclaw",
});
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
action: "repairDreamingArtifacts",
changed: true,
archiveDir: "/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-00-00-000Z",
archivedDreamsDiary: false,
archivedSessionCorpus: true,
archivedSessionIngestion: true,
warnings: [],
},
undefined,
);
});
it("dedupes exact dream diary duplicates for control-ui callers", async () => {
resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw");
dedupeDreamDiaryEntries.mockResolvedValue({
dreamsPath: "/tmp/openclaw/DREAMS.md",
removed: 2,
kept: 7,
});
const respond = vi.fn();
await invokeDoctorMemoryDedupeDreamDiary(respond);
expect(dedupeDreamDiaryEntries).toHaveBeenCalledWith({
workspaceDir: "/tmp/openclaw",
});
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
action: "dedupeDreamDiary",
path: "DREAMS.md",
found: false,
removedEntries: 2,
dedupedEntries: 2,
keptEntries: 7,
},
undefined,
);
});
});
describe("doctor.memory.dreamDiary", () => {

View File

@@ -15,9 +15,11 @@ import {
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { formatError } from "../server-utils.js";
import {
dedupeDreamDiaryEntries,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
previewGroundedRemMarkdown,
repairDreamingArtifacts,
writeBackfillDiaryEntries,
} from "./doctor.memory-core-runtime.js";
import { asRecord, normalizeTrimmedString } from "./record-shared.js";
@@ -125,7 +127,12 @@ export type DoctorMemoryDreamDiaryPayload = {
export type DoctorMemoryDreamActionPayload = {
agentId: string;
action: "backfill" | "reset" | "resetGroundedShortTerm";
action:
| "backfill"
| "reset"
| "resetGroundedShortTerm"
| "repairDreamingArtifacts"
| "dedupeDreamDiary";
path?: string;
found?: boolean;
scannedFiles?: number;
@@ -133,6 +140,14 @@ export type DoctorMemoryDreamActionPayload = {
replaced?: number;
removedEntries?: number;
removedShortTermEntries?: number;
changed?: boolean;
archiveDir?: string;
archivedDreamsDiary?: boolean;
archivedSessionCorpus?: boolean;
archivedSessionIngestion?: boolean;
warnings?: string[];
dedupedEntries?: number;
keptEntries?: number;
};
function extractIsoDayFromPath(filePath: string): string | null {
@@ -956,4 +971,38 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.repairDreamingArtifacts": async ({ respond }) => {
const cfg = loadConfig();
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const repair = await repairDreamingArtifacts({ workspaceDir });
const payload: DoctorMemoryDreamActionPayload = {
agentId,
action: "repairDreamingArtifacts",
changed: repair.changed,
archiveDir: repair.archiveDir,
archivedDreamsDiary: repair.archivedDreamsDiary,
archivedSessionCorpus: repair.archivedSessionCorpus,
archivedSessionIngestion: repair.archivedSessionIngestion,
warnings: repair.warnings,
};
respond(true, payload, undefined);
},
"doctor.memory.dedupeDreamDiary": async ({ respond }) => {
const cfg = loadConfig();
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const dedupe = await dedupeDreamDiaryEntries({ workspaceDir });
const dreamDiary = await readDreamDiary(workspaceDir);
const payload: DoctorMemoryDreamActionPayload = {
agentId,
action: "dedupeDreamDiary",
path: dreamDiary.path,
found: dreamDiary.found,
removedEntries: dedupe.removed,
dedupedEntries: dedupe.removed,
keptEntries: dedupe.kept,
};
respond(true, payload, undefined);
},
};

View File

@@ -8,7 +8,10 @@ import {
setupTelegramHeartbeatPluginRuntimeForTests,
withTempHeartbeatSandbox,
} from "./heartbeat-runner.test-utils.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
import {
enqueueSystemEvent,
resetSystemEventsForTest,
} from "./system-events.js";
beforeEach(() => {
setupTelegramHeartbeatPluginRuntimeForTests();
@@ -196,6 +199,56 @@ describe("Ghost reminder bug (issue #13317)", () => {
expect(sendTelegram).toHaveBeenCalled();
});
it("drains inspected cron events after a successful run so later heartbeats do not replay them", async () => {
await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => {
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
chatId: "155462274",
});
const getReplySpy = vi
.fn()
.mockResolvedValueOnce({ text: "Relay this cron update now" })
.mockResolvedValueOnce({ text: "HEARTBEAT_OK" });
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
enqueueSystemEvent("Cron: QMD maintenance completed", {
sessionKey,
contextKey: "cron:qmd-maintenance",
});
const first = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "interval",
deps: {
getReplyFromConfig: getReplySpy,
telegram: sendTelegram,
},
});
const second = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "interval",
deps: {
getReplyFromConfig: getReplySpy,
telegram: sendTelegram,
},
});
expect(first.status).toBe("ran");
expect(second.status).toBe("ran");
expect(getReplySpy).toHaveBeenCalledTimes(2);
const firstCtx = getReplySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
const secondCtx = getReplySpy.mock.calls[1]?.[0] as { Provider?: string; Body?: string };
expect(firstCtx.Provider).toBe("cron-event");
expect(firstCtx.Body).toContain("Cron: QMD maintenance completed");
expect(secondCtx.Provider).toBe("heartbeat");
expect(secondCtx.Body).toContain("Read HEARTBEAT.md");
expect(secondCtx.Body).not.toContain("Cron: QMD maintenance completed");
});
});
it("uses an internal-only cron prompt when delivery target is none", async () => {
const { result, sendTelegram, calledCtx } = await runHeartbeatCase({
tmpPrefix: "openclaw-cron-internal-",

View File

@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
import { resetSystemEventsForTest } from "./system-events.js";
vi.mock("./outbound/deliver.js", () => ({
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
@@ -12,6 +13,7 @@ vi.mock("./outbound/deliver.js", () => ({
afterEach(() => {
vi.restoreAllMocks();
resetSystemEventsForTest();
});
describe("runHeartbeatOnce isolated session key stability (#59493)", () => {

View File

@@ -97,7 +97,11 @@ import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatSenderContext,
} from "./outbound/targets.js";
import { peekSystemEventEntries, resolveSystemEventDeliveryContext } from "./system-events.js";
import {
consumeSystemEventEntries,
peekSystemEventEntries,
resolveSystemEventDeliveryContext,
} from "./system-events.js";
export type HeartbeatDeps = OutboundSendDeps &
ChannelHeartbeatDeps & {
@@ -801,6 +805,9 @@ export async function runHeartbeatOnce(opts: {
// If no tasks are due, skip heartbeat entirely
if (prompt === null) {
if (preflight.shouldInspectPendingEvents && preflight.pendingEventEntries.length > 0) {
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
}
return { status: "skipped", reason: "no-tasks-due" };
}
@@ -892,6 +899,13 @@ export async function runHeartbeatOnce(opts: {
await saveSessionStore(storePath, store);
};
const consumeInspectedSystemEvents = () => {
if (!preflight.shouldInspectPendingEvents || preflight.pendingEventEntries.length === 0) {
return;
}
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
};
const ctx = {
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
From: sender,
@@ -995,6 +1009,7 @@ export async function runHeartbeatOnce(opts: {
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -1031,6 +1046,7 @@ export async function runHeartbeatOnce(opts: {
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -1067,6 +1083,7 @@ export async function runHeartbeatOnce(opts: {
accountId: delivery.accountId,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -1088,6 +1105,7 @@ export async function runHeartbeatOnce(opts: {
accountId: delivery.accountId,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -1108,6 +1126,7 @@ export async function runHeartbeatOnce(opts: {
accountId: delivery.accountId,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -1183,6 +1202,7 @@ export async function runHeartbeatOnce(opts: {
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
} catch (err) {
const reason = formatErrorMessage(err);

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { isCronSystemEvent } from "./heartbeat-runner.js";
import {
consumeSystemEventEntries,
drainSystemEventEntries,
enqueueSystemEvent,
hasSystemEvents,
@@ -109,6 +110,16 @@ describe("system events (session routing)", () => {
expect(enqueueSystemEvent("Node connected", { sessionKey: key })).toBe(true);
});
it("consumes only the inspected prefix and leaves later queued events intact", () => {
const key = "agent:main:test-consume-prefix";
enqueueSystemEvent("first", { sessionKey: key, contextKey: "cron:first" });
const inspected = peekSystemEventEntries(key);
enqueueSystemEvent("second", { sessionKey: key, contextKey: "cron:second" });
expect(consumeSystemEventEntries(key, inspected).map((entry) => entry.text)).toEqual(["first"]);
expect(peekSystemEvents(key)).toEqual(["second"]);
});
it("resolves the newest effective delivery context from queued events", () => {
const key = "agent:main:test-delivery-context";
enqueueSystemEvent("Restarted", {

View File

@@ -128,6 +128,58 @@ export function drainSystemEventEntries(sessionKey: string): SystemEvent[] {
return out;
}
function areDeliveryContextsEqual(left?: DeliveryContext, right?: DeliveryContext): boolean {
if (!left && !right) {
return true;
}
if (!left || !right) {
return false;
}
return (
(left.channel ?? undefined) === (right.channel ?? undefined) &&
(left.to ?? undefined) === (right.to ?? undefined) &&
(left.threadId ?? undefined) === (right.threadId ?? undefined)
);
}
function areSystemEventsEqual(left: SystemEvent, right: SystemEvent): boolean {
return (
left.text === right.text &&
left.ts === right.ts &&
(left.contextKey ?? null) === (right.contextKey ?? null) &&
(left.trusted ?? true) === (right.trusted ?? true) &&
areDeliveryContextsEqual(left.deliveryContext, right.deliveryContext)
);
}
export function consumeSystemEventEntries(
sessionKey: string,
consumedEntries: readonly SystemEvent[],
): SystemEvent[] {
const key = requireSessionKey(sessionKey);
const entry = getSessionQueue(key);
if (!entry || entry.queue.length === 0 || consumedEntries.length === 0) {
return [];
}
if (
consumedEntries.length > entry.queue.length ||
!consumedEntries.every((event, index) => areSystemEventsEqual(entry.queue[index], event))
) {
return [];
}
const removed = entry.queue.splice(0, consumedEntries.length).map(cloneSystemEvent);
if (entry.queue.length === 0) {
entry.lastText = null;
entry.lastContextKey = null;
queues.delete(key);
} else {
const newest = entry.queue[entry.queue.length - 1];
entry.lastText = newest.text;
entry.lastContextKey = newest.contextKey ?? null;
}
return removed;
}
export function drainSystemEvents(sessionKey: string): string[] {
return drainSystemEventEntries(sessionKey).map((event) => event.text);
}

View File

@@ -150,4 +150,28 @@ describe("buildSessionEntry", () => {
Date.parse("2026-04-05T10:01:00.000Z"),
]);
});
it("flags dreaming narrative transcripts from bootstrap metadata", async () => {
const jsonlLines = [
JSON.stringify({
type: "custom",
customType: "openclaw:bootstrap-context:full",
data: {
runId: "dreaming-narrative-light-1775894400455",
sessionId: "sid-1",
},
}),
JSON.stringify({
type: "message",
message: { role: "user", content: "Write a dream diary entry from these memory fragments" },
}),
];
const filePath = path.join(tmpDir, "dreaming-session.jsonl");
await fs.writeFile(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
expect(entry?.generatedByDreamingNarrative).toBe(true);
});
});

View File

@@ -19,8 +19,32 @@ export type SessionFileEntry = {
lineMap: number[];
/** Maps each content line (0-indexed) to epoch ms; 0 means unknown timestamp. */
messageTimestampsMs: number[];
/** True when this transcript belongs to an internal dreaming narrative run. */
generatedByDreamingNarrative?: boolean;
};
function isDreamingNarrativeBootstrapRecord(record: unknown): boolean {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return false;
}
const candidate = record as {
type?: unknown;
customType?: unknown;
data?: unknown;
};
if (
candidate.type !== "custom" ||
candidate.customType !== "openclaw:bootstrap-context:full" ||
!candidate.data ||
typeof candidate.data !== "object" ||
Array.isArray(candidate.data)
) {
return false;
}
const runId = (candidate.data as { runId?: unknown }).runId;
return typeof runId === "string" && runId.startsWith("dreaming-narrative-");
}
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
const dir = resolveSessionTranscriptsDirForAgent(agentId);
try {
@@ -104,6 +128,7 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
const collected: string[] = [];
const lineMap: number[] = [];
const messageTimestampsMs: number[] = [];
let generatedByDreamingNarrative = false;
for (let jsonlIdx = 0; jsonlIdx < lines.length; jsonlIdx++) {
const line = lines[jsonlIdx];
if (!line.trim()) {
@@ -115,6 +140,9 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
} catch {
continue;
}
if (!generatedByDreamingNarrative && isDreamingNarrativeBootstrapRecord(record)) {
generatedByDreamingNarrative = true;
}
if (
!record ||
typeof record !== "object" ||
@@ -156,6 +184,7 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
content,
lineMap,
messageTimestampsMs,
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
};
} catch (err) {
log.debug(`Failed reading session file ${absPath}: ${String(err)}`);

View File

@@ -3,4 +3,8 @@
export { listMemoryFiles, normalizeExtraMemoryPaths } from "./host/internal.js";
export { readAgentMemoryFile } from "./host/read-file.js";
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
export type { MemorySearchManager, MemorySearchRuntimeDebug, MemorySearchResult } from "./host/types.js";
export type {
MemorySearchManager,
MemorySearchRuntimeDebug,
MemorySearchResult,
} from "./host/types.js";

View File

@@ -33,6 +33,10 @@ export const removeGroundedShortTermCandidates: RuntimeFacadeModule["removeGroun
loadRuntimeFacadeModule().removeGroundedShortTermCandidates(
...args,
)) as RuntimeFacadeModule["removeGroundedShortTermCandidates"];
export const repairDreamingArtifacts: RuntimeFacadeModule["repairDreamingArtifacts"] = ((...args) =>
loadRuntimeFacadeModule().repairDreamingArtifacts(
...args,
)) as RuntimeFacadeModule["repairDreamingArtifacts"];
export const previewGroundedRemMarkdown: ApiFacadeModule["previewGroundedRemMarkdown"] = ((
...args
@@ -41,6 +45,11 @@ export const previewGroundedRemMarkdown: ApiFacadeModule["previewGroundedRemMark
...args,
)) as ApiFacadeModule["previewGroundedRemMarkdown"];
export const dedupeDreamDiaryEntries: ApiFacadeModule["dedupeDreamDiaryEntries"] = ((...args) =>
loadApiFacadeModule().dedupeDreamDiaryEntries(
...args,
)) as ApiFacadeModule["dedupeDreamDiaryEntries"];
export const writeBackfillDiaryEntries: ApiFacadeModule["writeBackfillDiaryEntries"] = ((...args) =>
loadApiFacadeModule().writeBackfillDiaryEntries(
...args,

View File

@@ -17,6 +17,8 @@ export const auditShortTermPromotionArtifacts: FacadeModule["auditShortTermPromo
loadFacadeModule()["auditShortTermPromotionArtifacts"](
...args,
)) as FacadeModule["auditShortTermPromotionArtifacts"];
export const auditDreamingArtifacts: FacadeModule["auditDreamingArtifacts"] = ((...args) =>
loadFacadeModule()["auditDreamingArtifacts"](...args)) as FacadeModule["auditDreamingArtifacts"];
export const getBuiltinMemoryEmbeddingProviderDoctorMetadata: FacadeModule["getBuiltinMemoryEmbeddingProviderDoctorMetadata"] =
((...args) =>
loadFacadeModule()["getBuiltinMemoryEmbeddingProviderDoctorMetadata"](
@@ -37,8 +39,16 @@ export const repairShortTermPromotionArtifacts: FacadeModule["repairShortTermPro
loadFacadeModule()["repairShortTermPromotionArtifacts"](
...args,
)) as FacadeModule["repairShortTermPromotionArtifacts"];
export const repairDreamingArtifacts: FacadeModule["repairDreamingArtifacts"] = ((...args) =>
loadFacadeModule()["repairDreamingArtifacts"](
...args,
)) as FacadeModule["repairDreamingArtifacts"];
export type BuiltinMemoryEmbeddingProviderDoctorMetadata =
import("@openclaw/memory-core/runtime-api.js").BuiltinMemoryEmbeddingProviderDoctorMetadata;
export type DreamingArtifactsAuditSummary =
import("@openclaw/memory-core/runtime-api.js").DreamingArtifactsAuditSummary;
export type RepairDreamingArtifactsResult =
import("@openclaw/memory-core/runtime-api.js").RepairDreamingArtifactsResult;
export type RepairShortTermPromotionArtifactsResult =
import("@openclaw/memory-core/runtime-api.js").RepairShortTermPromotionArtifactsResult;
export type ShortTermAuditSummary =

View File

@@ -293,8 +293,10 @@ export const en: TranslationMap = {
},
scene: {
backfill: "Backfill",
dedupeDiary: "Dedupe Diary",
reset: "Reset",
clearGrounded: "Clear Replayed",
repairCache: "Repair Dream Cache",
working: "Working…",
},
phase: {

View File

@@ -73,10 +73,13 @@ import {
} from "./controllers/devices.ts";
import {
backfillDreamDiary,
copyDreamingArchivePath,
dedupeDreamDiary,
loadDreamDiary,
loadDreamingStatus,
loadWikiImportInsights,
loadWikiMemoryPalace,
repairDreamingArtifacts,
resetGroundedShortTerm,
resetDreamDiary,
resolveConfiguredDreaming,
@@ -233,6 +236,32 @@ function uniquePreserveOrder(values: string[]): string[] {
return output;
}
function isPluginExplicitlyEnabled(
configSnapshot: AppViewState["configSnapshot"],
pluginId: string,
): boolean {
const config = configSnapshot?.config;
if (!config || typeof config !== "object" || Array.isArray(config)) {
return true;
}
const plugins =
"plugins" in config && config.plugins && typeof config.plugins === "object"
? (config.plugins as Record<string, unknown>)
: null;
if (plugins?.enabled === false) {
return false;
}
const entries =
plugins && "entries" in plugins && plugins.entries && typeof plugins.entries === "object"
? (plugins.entries as Record<string, unknown>)
: null;
const entry = entries?.[pluginId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return true;
}
return (entry as { enabled?: unknown }).enabled !== false;
}
type DismissedUpdateBanner = {
latestVersion: string;
channel: string | null;
@@ -1985,9 +2014,12 @@ export function renderApp(state: AppViewState) {
modeSaving: state.dreamingModeSaving,
dreamDiaryLoading: state.dreamDiaryLoading,
dreamDiaryActionLoading: state.dreamDiaryActionLoading,
dreamDiaryActionMessage: state.dreamDiaryActionMessage,
dreamDiaryActionArchivePath: state.dreamDiaryActionArchivePath,
dreamDiaryError: state.dreamDiaryError,
dreamDiaryPath: state.dreamDiaryPath,
dreamDiaryContent: state.dreamDiaryContent,
memoryWikiEnabled: isPluginExplicitlyEnabled(state.configSnapshot, "memory-wiki"),
wikiImportInsightsLoading: state.wikiImportInsightsLoading,
wikiImportInsightsError: state.wikiImportInsightsError,
wikiImportInsights: state.wikiImportInsights,
@@ -1998,10 +2030,16 @@ export function renderApp(state: AppViewState) {
onRefreshDiary: () => loadDreamDiary(state),
onRefreshImports: () => loadWikiImportInsights(state),
onRefreshMemoryPalace: () => loadWikiMemoryPalace(state),
onOpenConfig: () => openConfigFile(state),
onOpenWikiPage: (lookup: string) => openWikiPage(lookup),
onBackfillDiary: () => backfillDreamDiary(state),
onCopyDreamingArchivePath: () => {
void copyDreamingArchivePath(state);
},
onDedupeDreamDiary: () => dedupeDreamDiary(state),
onResetDiary: () => resetDreamDiary(state),
onResetGroundedShortTerm: () => resetGroundedShortTerm(state),
onRepairDreamingArtifacts: () => repairDreamingArtifacts(state),
onRequestUpdate: requestHostUpdate,
})
: nothing}

View File

@@ -70,6 +70,8 @@ type SettingsHost = {
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryActionLoading: boolean;
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
dreamDiaryActionArchivePath: string | null;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
@@ -165,6 +167,8 @@ const createHost = (tab: Tab): SettingsHost => ({
dreamingModeSaving: false,
dreamDiaryLoading: false,
dreamDiaryActionLoading: false,
dreamDiaryActionMessage: null,
dreamDiaryActionArchivePath: null,
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,

View File

@@ -136,6 +136,8 @@ export type AppViewState = {
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryActionLoading: boolean;
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
dreamDiaryActionArchivePath: string | null;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;

View File

@@ -238,6 +238,8 @@ export class OpenClawApp extends LitElement {
@state() dreamingModeSaving = false;
@state() dreamDiaryLoading = false;
@state() dreamDiaryActionLoading = false;
@state() dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null = null;
@state() dreamDiaryActionArchivePath: string | null = null;
@state() dreamDiaryError: string | null = null;
@state() dreamDiaryPath: string | null = null;
@state() dreamDiaryContent: string | null = null;

View File

@@ -1,10 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import {
backfillDreamDiary,
copyDreamingArchivePath,
dedupeDreamDiary,
loadDreamDiary,
loadDreamingStatus,
loadWikiImportInsights,
loadWikiMemoryPalace,
repairDreamingArtifacts,
resetGroundedShortTerm,
resetDreamDiary,
resolveConfiguredDreaming,
@@ -27,6 +30,8 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
dreamingModeSaving: false,
dreamDiaryLoading: false,
dreamDiaryActionLoading: false,
dreamDiaryActionMessage: null,
dreamDiaryActionArchivePath: null,
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,
@@ -683,4 +688,108 @@ describe("dreaming controller", () => {
expect(state.dreamDiaryContent).toBe("keep existing diary");
expect(state.dreamDiaryActionLoading).toBe(false);
});
it("repairs dreaming artifacts and reloads only dreaming status", async () => {
const { state, request } = createState();
state.dreamDiaryContent = "keep existing diary";
const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true);
request.mockImplementation(async (method: string) => {
if (method === "doctor.memory.repairDreamingArtifacts") {
return {
action: "repairDreamingArtifacts",
changed: true,
archiveDir: "/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
archivedSessionCorpus: true,
archivedSessionIngestion: true,
};
}
if (method === "doctor.memory.status") {
return { dreaming: null };
}
return {};
});
const ok = await repairDreamingArtifacts(state);
expect(ok).toBe(true);
expect(confirmSpy).toHaveBeenCalled();
expect(request).toHaveBeenCalledWith("doctor.memory.repairDreamingArtifacts", {});
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
expect(request).not.toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
expect(state.dreamDiaryContent).toBe("keep existing diary");
expect(state.dreamDiaryActionMessage).toEqual({
kind: "success",
text: "Dream cache repair complete: archived session corpus, archived ingestion state. Archive: /tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
});
expect(state.dreamDiaryActionArchivePath).toBe(
"/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
);
expect(state.dreamDiaryActionLoading).toBe(false);
});
it("dedupes dream diary entries and reloads diary plus status", async () => {
const { state, request } = createState();
const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true);
request.mockImplementation(async (method: string) => {
if (method === "doctor.memory.dedupeDreamDiary") {
return {
action: "dedupeDreamDiary",
removedEntries: 2,
keptEntries: 5,
};
}
if (method === "doctor.memory.dreamDiary") {
return { found: true, path: "DREAMS.md", content: "deduped diary" };
}
if (method === "doctor.memory.status") {
return { dreaming: null };
}
return {};
});
const ok = await dedupeDreamDiary(state);
expect(ok).toBe(true);
expect(confirmSpy).toHaveBeenCalled();
expect(request).toHaveBeenCalledWith("doctor.memory.dedupeDreamDiary", {});
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
expect(state.dreamDiaryContent).toBe("deduped diary");
expect(state.dreamDiaryActionMessage).toEqual({
kind: "success",
text: "Removed 2 duplicate dream entries and kept 5.",
});
expect(state.dreamDiaryActionArchivePath).toBeNull();
expect(state.dreamDiaryActionLoading).toBe(false);
});
it("copies the dreaming repair archive path", async () => {
const { state } = createState();
state.dreamDiaryActionArchivePath =
"/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z";
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } } as unknown as Navigator);
const ok = await copyDreamingArchivePath(state);
expect(ok).toBe(true);
expect(writeText).toHaveBeenCalledWith(
"/tmp/openclaw/.openclaw-repair/dreaming/2026-04-11T22-10-00-000Z",
);
expect(state.dreamDiaryActionMessage).toEqual({
kind: "success",
text: "Archive path copied.",
});
});
it("does not run repair when confirmation is cancelled", async () => {
const { state, request } = createState();
vi.spyOn(globalThis, "confirm").mockReturnValue(false);
const ok = await repairDreamingArtifacts(state);
expect(ok).toBe(false);
expect(request).not.toHaveBeenCalled();
expect(state.dreamDiaryActionMessage).toBeNull();
});
});

View File

@@ -168,9 +168,17 @@ type DoctorMemoryDreamDiaryPayload = {
type DoctorMemoryDreamActionPayload = {
action?: unknown;
removedEntries?: unknown;
dedupedEntries?: unknown;
keptEntries?: unknown;
written?: unknown;
replaced?: unknown;
removedShortTermEntries?: unknown;
changed?: unknown;
archiveDir?: unknown;
archivedSessionCorpus?: unknown;
archivedSessionIngestion?: unknown;
archivedDreamsDiary?: unknown;
warnings?: unknown;
};
type WikiImportInsightsPayload = {
@@ -199,6 +207,8 @@ export type DreamingState = {
dreamingModeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryActionLoading: boolean;
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
dreamDiaryActionArchivePath: string | null;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
@@ -211,6 +221,64 @@ export type DreamingState = {
lastError: string | null;
};
function confirmDreamingAction(message: string): boolean {
if (typeof globalThis.confirm !== "function") {
return true;
}
return globalThis.confirm(message);
}
function buildDreamDiaryActionSuccessMessage(
method:
| "doctor.memory.backfillDreamDiary"
| "doctor.memory.resetDreamDiary"
| "doctor.memory.resetGroundedShortTerm"
| "doctor.memory.repairDreamingArtifacts"
| "doctor.memory.dedupeDreamDiary",
payload: DoctorMemoryDreamActionPayload | undefined,
): string {
switch (method) {
case "doctor.memory.dedupeDreamDiary": {
const removed =
typeof payload?.dedupedEntries === "number"
? payload.dedupedEntries
: typeof payload?.removedEntries === "number"
? payload.removedEntries
: 0;
const kept = typeof payload?.keptEntries === "number" ? payload.keptEntries : undefined;
return kept !== undefined
? `Removed ${removed} duplicate dream ${removed === 1 ? "entry" : "entries"} and kept ${kept}.`
: `Removed ${removed} duplicate dream ${removed === 1 ? "entry" : "entries"}.`;
}
case "doctor.memory.repairDreamingArtifacts": {
const actions: string[] = [];
const archiveDir = normalizeTrimmedString(payload?.archiveDir);
if (payload?.archivedSessionCorpus === true) {
actions.push("archived session corpus");
}
if (payload?.archivedSessionIngestion === true) {
actions.push("archived ingestion state");
}
if (payload?.archivedDreamsDiary === true) {
actions.push("archived dream diary");
}
if (actions.length === 0) {
return "Dream cache repair finished with no changes.";
}
return archiveDir
? `Dream cache repair complete: ${actions.join(", ")}. Archive: ${archiveDir}`
: `Dream cache repair complete: ${actions.join(", ")}.`;
}
case "doctor.memory.backfillDreamDiary":
return `Backfilled ${typeof payload?.written === "number" ? payload.written : 0} dream diary entries.`;
case "doctor.memory.resetDreamDiary":
return `Removed ${typeof payload?.removedEntries === "number" ? payload.removedEntries : 0} backfilled dream diary entries.`;
case "doctor.memory.resetGroundedShortTerm":
return `Cleared ${typeof payload?.removedShortTermEntries === "number" ? payload.removedShortTermEntries : 0} replayed short-term entries.`;
}
return "Dream diary action complete.";
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@@ -708,7 +776,9 @@ async function runDreamDiaryAction(
method:
| "doctor.memory.backfillDreamDiary"
| "doctor.memory.resetDreamDiary"
| "doctor.memory.resetGroundedShortTerm",
| "doctor.memory.resetGroundedShortTerm"
| "doctor.memory.repairDreamingArtifacts"
| "doctor.memory.dedupeDreamDiary",
options?: {
reloadDiary?: boolean;
},
@@ -716,20 +786,48 @@ async function runDreamDiaryAction(
if (!state.client || !state.connected || state.dreamDiaryActionLoading) {
return false;
}
if (
method === "doctor.memory.repairDreamingArtifacts" &&
!confirmDreamingAction(
"Repair Dream Cache? This archives derived dream cache files and rebuilds them from clean inputs. Your dream diary stays untouched.",
)
) {
return false;
}
if (
method === "doctor.memory.dedupeDreamDiary" &&
!confirmDreamingAction(
"Dedupe Dream Diary? This rewrites DREAMS.md and removes only exact duplicate diary entries.",
)
) {
return false;
}
state.dreamDiaryActionLoading = true;
state.dreamingStatusError = null;
state.dreamDiaryError = null;
state.dreamDiaryActionMessage = null;
state.dreamDiaryActionArchivePath = null;
try {
await state.client.request<DoctorMemoryDreamActionPayload>(method, {});
const payload = await state.client.request<DoctorMemoryDreamActionPayload>(method, {});
if (options?.reloadDiary !== false) {
await loadDreamDiary(state);
}
await loadDreamingStatus(state);
state.dreamDiaryActionArchivePath =
method === "doctor.memory.repairDreamingArtifacts"
? (normalizeTrimmedString(payload?.archiveDir) ?? null)
: null;
state.dreamDiaryActionMessage = {
kind: "success",
text: buildDreamDiaryActionSuccessMessage(method, payload),
};
return true;
} catch (err) {
const message = String(err);
state.dreamingStatusError = message;
state.lastError = message;
state.dreamDiaryActionArchivePath = null;
state.dreamDiaryActionMessage = { kind: "error", text: message };
return false;
} finally {
state.dreamDiaryActionLoading = false;
@@ -750,6 +848,44 @@ export async function resetGroundedShortTerm(state: DreamingState): Promise<bool
});
}
export async function repairDreamingArtifacts(state: DreamingState): Promise<boolean> {
return runDreamDiaryAction(state, "doctor.memory.repairDreamingArtifacts", {
reloadDiary: false,
});
}
export async function copyDreamingArchivePath(state: DreamingState): Promise<boolean> {
const path = state.dreamDiaryActionArchivePath;
if (!path) {
return false;
}
if (!globalThis.navigator?.clipboard?.writeText) {
state.dreamDiaryActionMessage = {
kind: "error",
text: "Could not copy archive path.",
};
return false;
}
try {
await globalThis.navigator.clipboard.writeText(path);
state.dreamDiaryActionMessage = {
kind: "success",
text: "Archive path copied.",
};
return true;
} catch {
state.dreamDiaryActionMessage = {
kind: "error",
text: "Could not copy archive path.",
};
return false;
}
}
export async function dedupeDreamDiary(state: DreamingState): Promise<boolean> {
return runDreamDiaryAction(state, "doctor.memory.dedupeDreamDiary");
}
async function writeDreamingPatch(
state: DreamingState,
patch: Record<string, unknown>,

View File

@@ -63,10 +63,13 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
modeSaving: false,
dreamDiaryLoading: false,
dreamDiaryActionLoading: false,
dreamDiaryActionMessage: null,
dreamDiaryActionArchivePath: null,
dreamDiaryError: null,
dreamDiaryPath: "DREAMS.md",
dreamDiaryContent:
"# Dream Diary\n\n<!-- openclaw:dreaming:diary:start -->\n\n---\n\n*April 5, 2026, 3:00 AM*\n\nThe repository whispered of forgotten endpoints tonight.\n\n<!-- openclaw:dreaming:diary:end -->",
memoryWikiEnabled: true,
wikiImportInsightsLoading: false,
wikiImportInsightsError: null,
wikiImportInsights: {
@@ -176,10 +179,14 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
onRefreshDiary: () => {},
onRefreshImports: () => {},
onRefreshMemoryPalace: () => {},
onOpenConfig: () => {},
onOpenWikiPage: async () => null,
onBackfillDiary: () => {},
onCopyDreamingArchivePath: () => {},
onDedupeDreamDiary: () => {},
onResetDiary: () => {},
onResetGroundedShortTerm: () => {},
onRepairDreamingArtifacts: () => {},
...overrides,
};
}
@@ -387,6 +394,27 @@ describe("dreaming view", () => {
setDreamSubTab("scene");
});
it("shows a memory-wiki enablement CTA when wiki subtabs are selected but the plugin is disabled", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("palace");
const onOpenConfig = vi.fn();
const container = renderInto(
buildProps({
memoryWikiEnabled: false,
onOpenConfig,
}),
);
expect(container.textContent).toContain("Memory Wiki is not enabled");
expect(container.textContent).toContain("plugins.entries.memory-wiki.enabled = true");
container
.querySelector<HTMLButtonElement>(".dreams-diary__empty-actions .btn")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onOpenConfig).toHaveBeenCalledTimes(1);
setDreamDiarySubTab("dreams");
setDreamSubTab("scene");
});
it("renders dream diary with parsed entry on diary tab", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("dreams");

View File

@@ -113,9 +113,12 @@ export type DreamingProps = {
modeSaving: boolean;
dreamDiaryLoading: boolean;
dreamDiaryActionLoading: boolean;
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
dreamDiaryActionArchivePath: string | null;
dreamDiaryError: string | null;
dreamDiaryPath: string | null;
dreamDiaryContent: string | null;
memoryWikiEnabled: boolean;
wikiImportInsightsLoading: boolean;
wikiImportInsightsError: string | null;
wikiImportInsights: WikiImportInsights | null;
@@ -126,6 +129,7 @@ export type DreamingProps = {
onRefreshDiary: () => void;
onRefreshImports: () => void;
onRefreshMemoryPalace: () => void;
onOpenConfig: () => void;
onOpenWikiPage: (lookup: string) => Promise<{
title: string;
path: string;
@@ -135,8 +139,11 @@ export type DreamingProps = {
updatedAt?: string;
} | null>;
onBackfillDiary: () => void;
onCopyDreamingArchivePath: () => void;
onDedupeDreamDiary: () => void;
onResetDiary: () => void;
onResetGroundedShortTerm: () => void;
onRepairDreamingArtifacts: () => void;
onRequestUpdate?: () => void;
};
@@ -763,6 +770,20 @@ function renderAdvancedSection(props: DreamingProps) {
<div class="dreams-advanced__summary">${summary}</div>
</div>
<div class="dreams-advanced__actions">
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
@click=${() => props.onDedupeDreamDiary()}
>
${t("dreaming.scene.dedupeDiary")}
</button>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
@click=${() => props.onRepairDreamingArtifacts()}
>
${t("dreaming.scene.repairCache")}
</button>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
@@ -788,6 +809,31 @@ function renderAdvancedSection(props: DreamingProps) {
</button>
</div>
</div>
${props.dreamDiaryActionMessage
? html`
<div
class="callout ${props.dreamDiaryActionMessage.kind === "success"
? "success"
: "danger"}"
role="status"
>
<div class="row wrap items-center gap-2">
<span>${props.dreamDiaryActionMessage.text}</span>
${props.dreamDiaryActionArchivePath
? html`
<button
class="btn btn--subtle btn--sm"
?disabled=${props.dreamDiaryActionLoading}
@click=${() => props.onCopyDreamingArchivePath()}
>
Copy archive path
</button>
`
: nothing}
</div>
</div>
`
: nothing}
<div class="dreams-advanced__sections">
${renderAdvancedEntryList({
@@ -1294,13 +1340,15 @@ function renderDreamDiaryEntries(props: DreamingProps) {
// ── Diary section renderer ────────────────────────────────────────────
function renderDiarySection(props: DreamingProps) {
const wikiTabSelected = _diarySubTab === "insights" || _diarySubTab === "palace";
const memoryWikiUnavailable = wikiTabSelected && !props.memoryWikiEnabled;
const diaryError =
_diarySubTab === "dreams"
? props.dreamDiaryError
: _diarySubTab === "insights"
? props.wikiImportInsightsError
: props.wikiMemoryPalaceError;
if (diaryError) {
if (diaryError && !memoryWikiUnavailable) {
return html`
<section class="dreams-diary">
<div class="dreams-diary__error">${diaryError}</div>
@@ -1356,15 +1404,19 @@ function renderDiarySection(props: DreamingProps) {
</div>
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving ||
(_diarySubTab === "dreams"
? props.dreamDiaryLoading
: _diarySubTab === "insights"
? props.wikiImportInsightsLoading
: props.wikiMemoryPalaceLoading)}
?disabled=${memoryWikiUnavailable
? false
: props.modeSaving ||
(_diarySubTab === "dreams"
? props.dreamDiaryLoading
: _diarySubTab === "insights"
? props.wikiImportInsightsLoading
: props.wikiMemoryPalaceLoading)}
@click=${() => {
_diaryPage = 0;
if (_diarySubTab === "dreams") {
if (memoryWikiUnavailable) {
props.onOpenConfig();
} else if (_diarySubTab === "dreams") {
props.onRefreshDiary();
} else if (_diarySubTab === "insights") {
props.onRefreshImports();
@@ -1373,27 +1425,48 @@ function renderDiarySection(props: DreamingProps) {
}
}}
>
${_diarySubTab === "dreams"
? props.dreamDiaryLoading
? t("dreaming.diary.reloading")
: t("dreaming.diary.reload")
: _diarySubTab === "insights"
? props.wikiImportInsightsLoading
? "Reloading…"
: "Reload"
: props.wikiMemoryPalaceLoading
? "Reloading…"
: "Reload"}
${memoryWikiUnavailable
? "How to enable"
: _diarySubTab === "dreams"
? props.dreamDiaryLoading
? t("dreaming.diary.reloading")
: t("dreaming.diary.reload")
: _diarySubTab === "insights"
? props.wikiImportInsightsLoading
? "Reloading…"
: "Reload"
: props.wikiMemoryPalaceLoading
? "Reloading…"
: "Reload"}
</button>
</div>
${renderDiarySubtabExplainer()}
</div>
${_diarySubTab === "dreams"
? renderDreamDiaryEntries(props)
: _diarySubTab === "insights"
? renderDiaryImportsSection(props)
: renderMemoryPalaceSection(props)}
${memoryWikiUnavailable
? html`
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">Memory Wiki is not enabled</div>
<div class="dreams-diary__empty-hint">
Imported Insights and Memory Palace are provided by the bundled
<code>memory-wiki</code> plugin.
</div>
<div class="dreams-diary__empty-hint">
Enable <code>plugins.entries.memory-wiki.enabled = true</code>, then reload this
tab.
</div>
<div class="dreams-diary__empty-actions">
<button class="btn btn--subtle btn--sm" @click=${() => props.onOpenConfig()}>
Open Config
</button>
</div>
</div>
`
: _diarySubTab === "dreams"
? renderDreamDiaryEntries(props)
: _diarySubTab === "insights"
? renderDiaryImportsSection(props)
: renderMemoryPalaceSection(props)}
${renderWikiPreviewOverlay(props)}
</section>
`;