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