mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:50:45 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user