Files
openclaw/extensions/memory-core/src/dreaming.test.ts
Vincent Koc 0609bf8581 feat(memory): harden dreaming and multilingual memory promotion (#60697)
* feat(memory): add recall audit and doctor repair flow

* refactor(memory): rename symbolic scoring and harden dreaming

* feat(memory): add multilingual concept vocabulary

* docs(changelog): note dreaming memory follow-up

* docs(changelog): shorten dreaming follow-up entry

* fix(memory): address review follow-ups

* chore(skills): tighten security triage trust model

* Update CHANGELOG.md
2026-04-04 15:48:13 +09:00

684 lines
21 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing,
reconcileShortTermDreamingCronJob,
resolveShortTermPromotionDreamingConfig,
runShortTermDreamingPromotionIfTriggered,
} from "./dreaming.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
const constants = __testing.constants;
type CronParam = NonNullable<Parameters<typeof reconcileShortTermDreamingCronJob>[0]["cron"]>;
type CronJobLike = Awaited<ReturnType<CronParam["list"]>>[number];
type CronAddInput = Parameters<CronParam["add"]>[0];
type CronPatch = Parameters<CronParam["update"]>[1];
function createLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createCronHarness(
initialJobs: CronJobLike[] = [],
opts?: { removeResult?: "boolean" | "unknown"; removeThrowsForIds?: string[] },
) {
const jobs: CronJobLike[] = [...initialJobs];
const addCalls: CronAddInput[] = [];
const updateCalls: Array<{ id: string; patch: CronPatch }> = [];
const removeCalls: string[] = [];
const cron: CronParam = {
async list() {
return jobs.map((job) => ({
...job,
...(job.schedule ? { schedule: { ...job.schedule } } : {}),
...(job.payload ? { payload: { ...job.payload } } : {}),
}));
},
async add(input) {
addCalls.push(input);
jobs.push({
id: `job-${jobs.length + 1}`,
name: input.name,
description: input.description,
enabled: input.enabled,
schedule: { ...input.schedule },
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: { ...input.payload },
createdAtMs: Date.now(),
});
return {};
},
async update(id, patch) {
updateCalls.push({ id, patch });
const index = jobs.findIndex((entry) => entry.id === id);
if (index < 0) {
return {};
}
const current = jobs[index]!;
jobs[index] = {
...current,
...(patch.name ? { name: patch.name } : {}),
...(patch.description ? { description: patch.description } : {}),
...(typeof patch.enabled === "boolean" ? { enabled: patch.enabled } : {}),
...(patch.schedule ? { schedule: { ...patch.schedule } } : {}),
...(patch.sessionTarget ? { sessionTarget: patch.sessionTarget } : {}),
...(patch.wakeMode ? { wakeMode: patch.wakeMode } : {}),
...(patch.payload ? { payload: { ...patch.payload } } : {}),
};
return {};
},
async remove(id) {
removeCalls.push(id);
if (opts?.removeThrowsForIds?.includes(id)) {
throw new Error(`remove failed for ${id}`);
}
const index = jobs.findIndex((entry) => entry.id === id);
if (index >= 0) {
jobs.splice(index, 1);
}
if (opts?.removeResult === "unknown") {
return {};
}
return { removed: index >= 0 };
},
};
return { cron, jobs, addCalls, updateCalls, removeCalls };
}
describe("short-term dreaming config", () => {
it("uses defaults and user timezone fallback", () => {
const cfg = {
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
} as OpenClawConfig;
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {},
cfg,
});
expect(resolved).toEqual({
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
timezone: "America/Los_Angeles",
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
});
});
it("reads explicit dreaming config values", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "deep",
frequency: "15 2 * * *",
timezone: "UTC",
limit: 7,
minScore: 0.4,
minRecallCount: 2,
minUniqueQueries: 3,
},
},
});
expect(resolved).toEqual({
enabled: true,
cron: "15 2 * * *",
timezone: "UTC",
limit: 7,
minScore: 0.4,
minRecallCount: 2,
minUniqueQueries: 3,
});
});
it("accepts cron alias and numeric string thresholds", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "deep",
cron: "5 1 * * *",
limit: "4",
minScore: "0.6",
minRecallCount: "2",
minUniqueQueries: "3",
},
},
});
expect(resolved).toEqual({
enabled: true,
cron: "5 1 * * *",
limit: 4,
minScore: 0.6,
minRecallCount: 2,
minUniqueQueries: 3,
});
});
it("treats blank numeric strings as unset and keeps preset defaults", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "deep",
limit: " ",
minScore: "",
minRecallCount: " ",
minUniqueQueries: "",
},
},
});
expect(resolved).toEqual({
enabled: true,
cron: constants.DREAMING_PRESET_DEFAULTS.deep.cron,
limit: constants.DREAMING_PRESET_DEFAULTS.deep.limit,
minScore: constants.DREAMING_PRESET_DEFAULTS.deep.minScore,
minRecallCount: constants.DREAMING_PRESET_DEFAULTS.deep.minRecallCount,
minUniqueQueries: constants.DREAMING_PRESET_DEFAULTS.deep.minUniqueQueries,
});
});
it("accepts limit=0 as an explicit no-op promotion cap", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
limit: 0,
},
},
});
expect(resolved.limit).toBe(0);
});
it("falls back to defaults when thresholds are negative", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "rem",
minScore: -0.2,
minRecallCount: -2,
minUniqueQueries: -4,
},
},
});
expect(resolved).toMatchObject({
enabled: true,
minScore: constants.DREAMING_PRESET_DEFAULTS.rem.minScore,
minRecallCount: constants.DREAMING_PRESET_DEFAULTS.rem.minRecallCount,
minUniqueQueries: constants.DREAMING_PRESET_DEFAULTS.rem.minUniqueQueries,
});
});
it("keeps dreaming disabled when mode is off", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "off",
},
},
});
expect(resolved.enabled).toBe(false);
});
});
describe("short-term dreaming startup event parsing", () => {
it("resolves cron service from gateway startup event deps", () => {
const harness = createCronHarness();
const resolved = __testing.resolveCronServiceFromStartupEvent({
type: "gateway",
action: "startup",
context: {
deps: {
cron: harness.cron,
},
},
});
expect(resolved).toBe(harness.cron);
});
});
describe("short-term dreaming cron reconciliation", () => {
it("creates a managed cron job when enabled", async () => {
const harness = createCronHarness();
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: true,
cron: "0 1 * * *",
timezone: "UTC",
limit: 8,
minScore: 0.5,
minRecallCount: 4,
minUniqueQueries: 5,
},
logger,
});
expect(result.status).toBe("added");
expect(harness.addCalls).toHaveLength(1);
expect(harness.addCalls[0]).toMatchObject({
name: constants.MANAGED_DREAMING_CRON_NAME,
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: constants.DREAMING_SYSTEM_EVENT_TEXT,
},
schedule: {
kind: "cron",
expr: "0 1 * * *",
tz: "UTC",
},
});
});
it("updates drifted managed jobs and prunes duplicates", async () => {
const desiredConfig = {
enabled: true,
cron: "0 3 * * *",
timezone: "America/Los_Angeles",
limit: 10,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
} as const;
const desired = __testing.buildManagedDreamingCronJob(desiredConfig);
const stalePrimary: CronJobLike = {
id: "job-primary",
name: desired.name,
description: desired.description,
enabled: false,
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "stale-text",
},
createdAtMs: 1,
};
const duplicate: CronJobLike = {
...desired,
id: "job-duplicate",
createdAtMs: 2,
};
const unmanaged: CronJobLike = {
id: "job-unmanaged",
name: "other",
description: "not managed",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
createdAtMs: 3,
};
const harness = createCronHarness([stalePrimary, duplicate, unmanaged]);
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: desiredConfig,
logger,
});
expect(result.status).toBe("updated");
expect(result.removed).toBe(1);
expect(harness.removeCalls).toEqual(["job-duplicate"]);
expect(harness.updateCalls).toHaveLength(1);
expect(harness.updateCalls[0]).toMatchObject({
id: "job-primary",
patch: {
enabled: true,
schedule: desired.schedule,
payload: desired.payload,
},
});
});
it("removes managed dreaming jobs when disabled", async () => {
const managedJob: CronJobLike = {
id: "job-managed",
name: constants.MANAGED_DREAMING_CRON_NAME,
description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
enabled: true,
schedule: { kind: "cron", expr: "0 3 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
createdAtMs: 10,
};
const unmanagedJob: CronJobLike = {
id: "job-other",
name: "Daily report",
description: "other",
enabled: true,
schedule: { kind: "cron", expr: "0 7 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "report" },
createdAtMs: 11,
};
const harness = createCronHarness([managedJob, unmanagedJob]);
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result).toEqual({ status: "disabled", removed: 1 });
expect(harness.removeCalls).toEqual(["job-managed"]);
expect(harness.jobs.map((entry) => entry.id)).toEqual(["job-other"]);
});
it("does not overcount removed jobs when cron remove result is unknown", async () => {
const managedJob: CronJobLike = {
id: "job-managed",
name: constants.MANAGED_DREAMING_CRON_NAME,
description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
enabled: true,
schedule: { kind: "cron", expr: "0 3 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
createdAtMs: 10,
};
const harness = createCronHarness([managedJob], { removeResult: "unknown" });
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result.removed).toBe(0);
expect(harness.removeCalls).toEqual(["job-managed"]);
});
it("warns and continues when disabling managed jobs hits a remove error", async () => {
const managedJob: CronJobLike = {
id: "job-managed",
name: constants.MANAGED_DREAMING_CRON_NAME,
description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
enabled: true,
schedule: { kind: "cron", expr: "0 3 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
createdAtMs: 10,
};
const harness = createCronHarness([managedJob], { removeThrowsForIds: ["job-managed"] });
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result).toEqual({ status: "disabled", removed: 0 });
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("failed to remove managed dreaming cron job job-managed"),
);
});
});
describe("short-term dreaming trigger", () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tempDirs.length = 0;
});
it("applies promotions when the managed dreaming heartbeat event fires", async () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-"));
tempDirs.push(workspaceDir);
await recordShortTermRecalls({
workspaceDir,
query: "backup policy",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
});
it("keeps one-off recalls out of long-term memory under default thresholds", async () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-strict-"));
tempDirs.push(workspaceDir);
await recordShortTermRecalls({
workspaceDir,
query: "glacier",
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.95,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs
.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8")
.catch((err: unknown) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw err;
});
expect(memoryText).toBe("");
});
it("ignores non-heartbeat triggers", async () => {
const logger = createLogger();
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "user",
workspaceDir: "/tmp/workspace",
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
},
logger,
});
expect(result).toBeUndefined();
});
it("skips dreaming promotion cleanly when limit is zero", async () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-limit-zero-"));
tempDirs.push(workspaceDir);
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 0,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
},
logger,
});
expect(result).toEqual({
handled: true,
reason: "memory-core: short-term dreaming disabled by limit",
});
expect(logger.info).toHaveBeenCalledWith(
"memory-core: dreaming promotion skipped because limit=0.",
);
await expect(fs.access(path.join(workspaceDir, "MEMORY.md"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("repairs recall artifacts before dreaming promotion runs", async () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-repair-"));
tempDirs.push(workspaceDir);
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-01T00:00:00.000Z",
entries: {
"memory:memory/2026-04-03.md:1:2": {
key: "memory:memory/2026-04-03.md:1:2",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "Move backups to S3 Glacier and sync router failover notes.",
recallCount: 3,
totalScore: 2.7,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-03T00:00:00.000Z",
queryHashes: ["abc", "abc", "def"],
recallDays: ["2026-04-01", "2026-04-01", "2026-04-03"],
conceptTags: [],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
},
logger,
});
expect(result?.handled).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("normalized recall artifacts before dreaming"),
);
const repaired = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
entries: Record<
string,
{ queryHashes?: string[]; recallDays?: string[]; conceptTags?: string[] }
>;
};
expect(repaired.entries["memory:memory/2026-04-03.md:1:2"]?.queryHashes).toEqual([
"abc",
"def",
]);
expect(repaired.entries["memory:memory/2026-04-03.md:1:2"]?.recallDays).toEqual([
"2026-04-01",
"2026-04-03",
]);
expect(repaired.entries["memory:memory/2026-04-03.md:1:2"]?.conceptTags).toEqual(
expect.arrayContaining(["glacier", "router", "failover"]),
);
});
});