fix(memory-core): unblock dreaming-only promotion

This commit is contained in:
Vincent Koc
2026-04-12 18:14:06 +01:00
parent 686e5976df
commit 077cfca229
4 changed files with 182 additions and 3 deletions

View File

@@ -41,7 +41,11 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
},
};
function createHarness(config: OpenClawConfig, workspaceDir?: string) {
function createHarness(
config: OpenClawConfig,
workspaceDir?: string,
subagent?: Parameters<typeof __testing.runPhaseIfTriggered>[0]["subagent"],
) {
const logger = {
info: vi.fn(),
warn: vi.fn(),
@@ -82,6 +86,7 @@ function createHarness(config: OpenClawConfig, workspaceDir?: string) {
workspaceDir: ctx.workspaceDir,
cfg: resolvedConfig,
logger,
subagent,
phase: "light",
eventText: __testing.constants.LIGHT_SLEEP_EVENT_TEXT,
config: light,
@@ -96,6 +101,7 @@ function createHarness(config: OpenClawConfig, workspaceDir?: string) {
workspaceDir: ctx.workspaceDir,
cfg: resolvedConfig,
logger,
subagent,
phase: "rem",
eventText: __testing.constants.REM_SLEEP_EVENT_TEXT,
config: rem,
@@ -104,6 +110,23 @@ function createHarness(config: OpenClawConfig, workspaceDir?: string) {
return { beforeAgentReply, logger };
}
function createMockNarrativeSubagent(response = "The archive hummed softly.") {
const run = vi.fn(async (_params: { sessionKey: string; message: string }) => ({
runId: "dream-run-1",
}));
const waitForRun = vi.fn(async () => ({ status: "ok" }));
const getSessionMessages = vi.fn(async () => ({
messages: [{ role: "assistant", content: response }],
}));
const deleteSession = vi.fn(async () => {});
return {
run,
waitForRun,
getSessionMessages,
deleteSession,
};
}
function setDreamingTestTime(offsetMinutes = 0) {
vi.setSystemTime(new Date(DREAMING_TEST_BASE_TIME.getTime() + offsetMinutes * 60_000));
}
@@ -1448,4 +1471,83 @@ describe("memory-core dreaming phases", () => {
remHits: 1,
});
});
it("passes staged light-dreaming snippets into the narrative pipeline", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagent = createMockNarrativeSubagent("The backup plan glowed like cold storage.");
const { beforeAgentReply } = createHarness(LIGHT_DREAMING_TEST_CONFIG, workspaceDir, subagent);
await withDreamingTestClock(async () => {
await writeDailyNote(workspaceDir, [
`# ${DREAMING_TEST_DAY}`,
"",
"- Move backups to S3 Glacier.",
"- Keep retention at 365 days.",
]);
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
expect(subagent.run).toHaveBeenCalledTimes(1);
const firstRun = subagent.run.mock.calls[0]?.[0];
expect(firstRun?.message).toContain("Move backups to S3 Glacier.");
expect(firstRun?.message).toContain("Keep retention at 365 days.");
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"The backup plan glowed like cold storage.",
);
});
it("passes rem-dreaming snippets into the narrative pipeline", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagent = createMockNarrativeSubagent("The traces braided themselves into a map.");
const { beforeAgentReply } = createHarness(
{
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
rem: {
enabled: true,
limit: 10,
lookbackDays: 7,
minPatternStrength: 0,
},
},
},
},
},
},
},
},
workspaceDir,
subagent,
);
await withDreamingTestClock(async () => {
await writeDailyNote(workspaceDir, [
`# ${DREAMING_TEST_DAY}`,
"",
"- Move backups to S3 Glacier.",
"- Keep retention at 365 days.",
"- Rotate access keys after the audit.",
]);
setDreamingTestTime(5);
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_rem_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
});
expect(subagent.run).toHaveBeenCalledTimes(1);
const firstRun = subagent.run.mock.calls[0]?.[0];
expect(firstRun?.message).toContain("Move backups to S3 Glacier.");
expect(firstRun?.message).toContain("Keep retention at 365 days.");
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"The traces braided themselves into a map.",
);
});
});

View File

@@ -179,6 +179,80 @@ describe("short-term promotion", () => {
});
});
it("lets repeated dreaming-only daily signals clear the default promotion gates", async () => {
await withTempWorkspace(async (workspaceDir) => {
const queryDays = ["2026-04-01", "2026-04-02", "2026-04-03"];
let candidateKey = "";
for (const [index, day] of queryDays.entries()) {
const nowMs = Date.parse(`${day}T10:00:00.000Z`);
await recordShortTermRecalls({
workspaceDir,
query: `__dreaming_daily__:${day}`,
signalType: "daily",
dedupeByQueryPerDay: true,
dayBucket: day,
nowMs,
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 2,
score: 0.62,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs,
});
candidateKey = ranked[0]?.key ?? candidateKey;
expect(candidateKey).toBeTruthy();
await recordDreamingPhaseSignals({
workspaceDir,
phase: "light",
keys: [candidateKey],
nowMs,
});
await recordDreamingPhaseSignals({
workspaceDir,
phase: "rem",
keys: [candidateKey],
nowMs: nowMs + 60_000,
});
if (index < 2) {
const beforeThreshold = await rankShortTermPromotionCandidates({
workspaceDir,
nowMs,
});
expect(beforeThreshold).toHaveLength(0);
}
}
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
nowMs: Date.parse("2026-04-03T10:01:00.000Z"),
});
expect(ranked).toHaveLength(1);
expect(ranked[0]).toMatchObject({
recallCount: 0,
dailyCount: 3,
uniqueQueries: 3,
});
expect(ranked[0]?.recallDays).toEqual(queryDays);
expect(ranked[0]?.score).toBeGreaterThanOrEqual(0.75);
});
});
it("lets grounded durable evidence satisfy default deep thresholds", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [

View File

@@ -31,8 +31,10 @@ const SHORT_TERM_LOCK_RELATIVE_PATH = path.join("memory", ".dreams", "short-term
const SHORT_TERM_LOCK_WAIT_TIMEOUT_MS = 10_000;
const SHORT_TERM_LOCK_STALE_MS = 60_000;
const SHORT_TERM_LOCK_RETRY_DELAY_MS = 40;
const PHASE_SIGNAL_LIGHT_BOOST_MAX = 0.05;
const PHASE_SIGNAL_REM_BOOST_MAX = 0.08;
// Repeated dreaming revisits should be able to clear the default promotion gate
// without requiring separate organic recall traffic for the same snippet.
const PHASE_SIGNAL_LIGHT_BOOST_MAX = 0.06;
const PHASE_SIGNAL_REM_BOOST_MAX = 0.09;
const PHASE_SIGNAL_HALF_LIFE_DAYS = 14;
const inProcessShortTermLocks = new Map<string, Promise<void>>();
const ensuredShortTermDirs = new Map<string, Promise<void>>();