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

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

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

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

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

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

* Fix dreaming replay and recovery flows

* fix: prevent dreaming event loss and diary write races

* chore: add changelog entry for memory fixes

* fix: harden dreaming repair and diary writes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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