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

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Plugins/memory: restore cached memory capability public artifacts on plugin-registry cache hits so memory-backed artifact surfaces stay visible after warm loads. Thanks @sercada and @vincentkoc.
- Gateway/cron: preserve requested isolated-agent config across runtime reloads so subagent jobs and heartbeat overrides keep the right workspace and heartbeat settings when the hot-loaded snapshot is stale. Thanks @l0cka and @vincentkoc.
- Gateway/plugins: always send a non-empty `idempotencyKey` for plugin subagent runs, so dreaming narrative jobs stop failing gateway schema validation. (#65354) Thanks @CodeForgeNet and @vincentkoc.
- Dreaming/promotion: raise phase reinforcement enough for repeated dreaming-only revisits to clear the default durable-memory gate after multiple days, instead of stalling just below the score threshold. Thanks @vincentkoc.
- CLI/plugins: honor `memory-wiki` when `plugins.allow` is set for `openclaw wiki`, and register `wiki` as the plugin-owned command alias so doctor/config stop treating it as stale. (#64779) Thanks @feiskyer and @vincentkoc.
- Cron/isolated sessions: persist the right transcript path for each isolated run, including fresh session rollovers, so cron runs stop appending to stale session files. Thanks @samrusani and @vincentkoc.
- CLI/memory-wiki: pass the active app config into the metadata registrar so built `openclaw wiki` commands resolve the live wiki plugin config instead of silently falling back to defaults. (#65012) Thanks @leonardsellem and @vincentkoc.

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>>();