diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c0ca00893..d4d6640ad58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index c01e1f52d49..c73cbad1d30 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -41,7 +41,11 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = { }, }; -function createHarness(config: OpenClawConfig, workspaceDir?: string) { +function createHarness( + config: OpenClawConfig, + workspaceDir?: string, + subagent?: Parameters[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.", + ); + }); }); diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 8a320fc89e7..0fee7f1ad4a 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -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", [ diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 416a6909e8f..0c7d438a5a9 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -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>(); const ensuredShortTermDirs = new Map>();