From 6e3155ca841ae248c97c3f20c50b2fa3246c25e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 15:58:35 +0100 Subject: [PATCH] feat(memory-core): add dreaming aging controls --- docs/.generated/config-baseline.sha256 | 4 +- docs/cli/memory.md | 9 +- .../{memory-dreaming.md => dreaming.md} | 41 +++--- docs/concepts/memory.md | 4 +- docs/docs.json | 2 +- docs/gateway/configuration-reference.md | 4 +- docs/reference/memory-config.md | 36 +++--- docs/tools/slash-commands.md | 2 +- extensions/memory-core/openclaw.plugin.json | 18 +++ extensions/memory-core/src/cli.runtime.ts | 25 ++-- .../memory-core/src/dreaming-command.test.ts | 3 + .../memory-core/src/dreaming-command.ts | 4 +- extensions/memory-core/src/dreaming.test.ts | 16 +++ extensions/memory-core/src/dreaming.ts | 13 +- .../src/short-term-promotion.test.ts | 121 ++++++++++++++++++ .../memory-core/src/short-term-promotion.ts | 10 ++ src/gateway/server-methods/doctor.test.ts | 4 + src/gateway/server-methods/doctor.ts | 4 + src/memory-host-sdk/dreaming.test.ts | 6 + src/memory-host-sdk/dreaming.ts | 31 +++++ 20 files changed, 300 insertions(+), 57 deletions(-) rename docs/concepts/{memory-dreaming.md => dreaming.md} (85%) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 102f28f6b1a..d1b83809f00 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ea126fa950fe65c4f7be68c92ff06f3a2256dfe1e770c8643c93b132528bb217 config-baseline.json +87def4cf58f22dcadb02e7963cda1722e77519e2532a501a2e40145967477401 config-baseline.json 587eb0dde83443aa49d743010d224cdc2d7bb6c9d21c3c4effae44f5a06913c5 config-baseline.core.json 3c999707b167138de34f6255e3488b99e404c5132d3fc5879a1fa12d815c31f5 config-baseline.channel.json -31a7d5fd79cb3591a6469c13e5ab42010f8c54e9c1f74da0aad4a1629969085d config-baseline.plugin.json +c2650b75c4bd7cbaf5f3735193671b4c48cbdc61c2bdc465be913c6eb1c09b24 config-baseline.plugin.json diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 3dc6c8d01e1..ce820db156e 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -103,12 +103,13 @@ Dreaming is the overnight reflection pass for memory. It is called "dreaming" be - Promotion into `MEMORY.md` only happens when quality thresholds are met, so long-term memory stays high signal instead of collecting one-off details. - Automatic runs fan out across configured memory workspaces, so one agent's sessions consolidate into that agent's memory workspace instead of only the default session. - Promotion re-reads the live daily note before writing to `MEMORY.md`, so edited or deleted short-term snippets do not get promoted from stale recall-store snapshots. +- Scheduled and manual `memory promote` runs share the same dreaming defaults unless you pass CLI threshold overrides. Default mode presets: -- `core`: daily at `0 3 * * *`, `minScore=0.75`, `minRecallCount=3`, `minUniqueQueries=2` -- `deep`: every 12 hours (`0 */12 * * *`), `minScore=0.8`, `minRecallCount=3`, `minUniqueQueries=3` -- `rem`: every 6 hours (`0 */6 * * *`), `minScore=0.85`, `minRecallCount=4`, `minUniqueQueries=3` +- `core`: daily at `0 3 * * *`, `minScore=0.75`, `minRecallCount=3`, `minUniqueQueries=2`, `recencyHalfLifeDays=14` +- `deep`: every 12 hours (`0 */12 * * *`), `minScore=0.8`, `minRecallCount=3`, `minUniqueQueries=3`, `recencyHalfLifeDays=14` +- `rem`: every 6 hours (`0 */6 * * *`), `minScore=0.85`, `minRecallCount=4`, `minUniqueQueries=3`, `recencyHalfLifeDays=14` Example: @@ -134,5 +135,5 @@ Notes: - `memory status` includes any extra paths configured via `memorySearch.extraPaths`. - If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast. - Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error. -- Dreaming cadence defaults to each mode's preset schedule. Override cadence with `plugins.entries.memory-core.config.dreaming.cron` as a cron expression (for example `0 3 * * *`) and fine-tune with `timezone`, `limit`, `minScore`, `minRecallCount`, and `minUniqueQueries`. +- Dreaming cadence defaults to each mode's preset schedule. Override cadence with `plugins.entries.memory-core.config.dreaming.cron` as a cron expression (for example `0 3 * * *`) and fine-tune with `timezone`, `limit`, `minScore`, `minRecallCount`, `minUniqueQueries`, `recencyHalfLifeDays`, and `maxAgeDays`. - Set `plugins.entries.memory-core.config.dreaming.verboseLogging` to `true` to emit per-run candidate and apply details into the normal gateway logs while tuning the feature. diff --git a/docs/concepts/memory-dreaming.md b/docs/concepts/dreaming.md similarity index 85% rename from docs/concepts/memory-dreaming.md rename to docs/concepts/dreaming.md index 8d70d639f2f..ac232833d5d 100644 --- a/docs/concepts/memory-dreaming.md +++ b/docs/concepts/dreaming.md @@ -41,14 +41,14 @@ Promotion requires all configured threshold gates to pass, not just one signal. ### Signal weights -| Signal | Weight | Description | -| ------------------- | ------ | ------------------------------------------------ | -| Frequency | 0.24 | How often the same entry was recalled | -| Relevance | 0.30 | Average recall scores when retrieved | -| Query diversity | 0.15 | Count of distinct query intents that surfaced it | -| Recency | 0.15 | Temporal decay (14-day half-life) | -| Consolidation | 0.10 | Reward recalls repeated across multiple days | -| Conceptual richness | 0.06 | Reward entries with richer derived concept tags | +| Signal | Weight | Description | +| ------------------- | ------ | -------------------------------------------------- | +| Frequency | 0.24 | How often the same entry was recalled | +| Relevance | 0.30 | Average recall scores when retrieved | +| Query diversity | 0.15 | Count of distinct query intents that surfaced it | +| Recency | 0.15 | Temporal decay (`recencyHalfLifeDays`, default 14) | +| Consolidation | 0.10 | Reward recalls repeated across multiple days | +| Conceptual richness | 0.06 | Reward entries with richer derived concept tags | ## How it works @@ -71,12 +71,12 @@ Promotion requires all configured threshold gates to pass, not just one signal. `dreaming.mode` controls cadence and default thresholds: -| Mode | Cadence | minScore | minRecallCount | minUniqueQueries | -| ------ | -------------- | -------- | -------------- | ---------------- | -| `off` | Disabled | -- | -- | -- | -| `core` | Daily 3 AM | 0.75 | 3 | 2 | -| `rem` | Every 6 hours | 0.85 | 4 | 3 | -| `deep` | Every 12 hours | 0.80 | 3 | 3 | +| Mode | Cadence | minScore | minRecallCount | minUniqueQueries | recencyHalfLifeDays | +| ------ | -------------- | -------- | -------------- | ---------------- | ------------------- | +| `off` | Disabled | -- | -- | -- | -- | +| `core` | Daily 3 AM | 0.75 | 3 | 2 | 14 | +| `rem` | Every 6 hours | 0.85 | 4 | 3 | 14 | +| `deep` | Every 12 hours | 0.80 | 3 | 3 | 14 | ## Scheduling model @@ -91,6 +91,8 @@ You can still tune behavior with explicit overrides such as: - `dreaming.minScore` - `dreaming.minRecallCount` - `dreaming.minUniqueQueries` +- `dreaming.recencyHalfLifeDays` +- `dreaming.maxAgeDays` - `dreaming.verboseLogging` ## Configure @@ -102,7 +104,9 @@ You can still tune behavior with explicit overrides such as: "memory-core": { "config": { "dreaming": { - "mode": "core" + "mode": "core", + "recencyHalfLifeDays": 21, + "maxAgeDays": 30 } } } @@ -141,6 +145,9 @@ openclaw memory promote --limit 5 # Include already-promoted entries openclaw memory promote --include-promoted +# Manual runs inherit dreaming thresholds unless you override them +openclaw memory promote --apply + # Check dreaming status openclaw memory status --deep ``` @@ -154,6 +161,10 @@ memory stats (short-term count, long-term count, promoted count) and the next scheduled cycle time. Daily counters honor `dreaming.timezone` when set and otherwise fall back to the configured user timezone. +Manual `openclaw memory promote` runs use the same dreaming thresholds by +default, so scheduled and on-demand promotion stay aligned unless you pass CLI +overrides. + ## Further reading - [Memory](/concepts/memory) diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 9969cc3001c..f77fa5631df 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -98,7 +98,7 @@ It is designed to keep long-term memory high signal: diversity gates. For mode behavior (`off`, `core`, `rem`, `deep`), scoring signals, and tuning -knobs, see [Dreaming (experimental)](/concepts/memory-dreaming). +knobs, see [Dreaming (experimental)](/concepts/dreaming). ## CLI @@ -115,7 +115,7 @@ openclaw memory index --force # Rebuild the index - [Honcho Memory](/concepts/memory-honcho) -- AI-native cross-session memory - [Memory Search](/concepts/memory-search) -- search pipeline, providers, and tuning -- [Dreaming (experimental)](/concepts/memory-dreaming) -- background promotion +- [Dreaming (experimental)](/concepts/dreaming) -- background promotion from short-term recall to long-term memory - [Memory configuration reference](/reference/memory-config) -- all config knobs - [Compaction](/concepts/compaction) -- how compaction interacts with memory diff --git a/docs/docs.json b/docs/docs.json index 33923536277..9474095f966 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1078,7 +1078,7 @@ "concepts/memory-qmd", "concepts/memory-honcho", "concepts/memory-search", - "concepts/memory-dreaming" + "concepts/dreaming" ] }, "concepts/compaction" diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b64655632d1..af790734d88 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2641,7 +2641,7 @@ See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM - `plugins.entries.xai.config.xSearch`: xAI X Search (Grok web search) settings. - `enabled`: enable the X Search provider. - `model`: Grok model to use for search (e.g. `"grok-4-1-fast"`). -- `plugins.entries.memory-core.config.dreaming`: memory dreaming (experimental) settings. See [Dreaming](/concepts/memory-dreaming) for modes and thresholds. +- `plugins.entries.memory-core.config.dreaming`: memory dreaming (experimental) settings. See [Dreaming](/concepts/dreaming) for modes and thresholds. - `mode`: dreaming cadence preset (`"off"`, `"core"`, `"rem"`, `"deep"`). Default: `"off"`. - `cron`: optional cron expression override for the dreaming schedule. - `timezone`: timezone for schedule evaluation (falls back to `agents.defaults.userTimezone`). @@ -2649,6 +2649,8 @@ See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM - `minScore`: minimum weighted score threshold for promotion. - `minRecallCount`: minimum recall count threshold. - `minUniqueQueries`: minimum distinct query count threshold. + - `recencyHalfLifeDays`: days for the recency score to decay by half. Default: `14`. + - `maxAgeDays`: optional maximum daily-note age in days allowed for promotion. - `verboseLogging`: emit detailed per-run dreaming logs into the normal gateway log stream. - Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 8a7b5a5e595..918bf59bb48 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -377,27 +377,29 @@ Default is DM-only. `match.keyPrefix` matches the normalized session key; Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`. For conceptual details and chat -commands, see [Dreaming](/concepts/memory-dreaming). +commands, see [Dreaming](/concepts/dreaming). -| Key | Type | Default | Description | -| ------------------ | --------- | -------------- | ----------------------------------------- | -| `mode` | `string` | `"off"` | Preset: `off`, `core`, `rem`, or `deep` | -| `cron` | `string` | preset default | Cron expression override for the schedule | -| `timezone` | `string` | user timezone | Timezone for schedule evaluation | -| `limit` | `number` | preset default | Max candidates to promote per cycle | -| `minScore` | `number` | preset default | Minimum weighted score for promotion | -| `minRecallCount` | `number` | preset default | Minimum recall count threshold | -| `minUniqueQueries` | `number` | preset default | Minimum distinct query count threshold | -| `verboseLogging` | `boolean` | `false` | Emit detailed per-run dreaming logs | +| Key | Type | Default | Description | +| --------------------- | --------- | -------------- | ----------------------------------------- | +| `mode` | `string` | `"off"` | Preset: `off`, `core`, `rem`, or `deep` | +| `cron` | `string` | preset default | Cron expression override for the schedule | +| `timezone` | `string` | user timezone | Timezone for schedule evaluation | +| `limit` | `number` | preset default | Max candidates to promote per cycle | +| `minScore` | `number` | preset default | Minimum weighted score for promotion | +| `minRecallCount` | `number` | preset default | Minimum recall count threshold | +| `minUniqueQueries` | `number` | preset default | Minimum distinct query count threshold | +| `recencyHalfLifeDays` | `number` | `14` | Days for recency score to decay by half | +| `maxAgeDays` | `number` | unset | Optional max daily-note age for promotion | +| `verboseLogging` | `boolean` | `false` | Emit detailed per-run dreaming logs | ### Preset defaults -| Mode | Cadence | minScore | minRecallCount | minUniqueQueries | -| ------ | -------------- | -------- | -------------- | ---------------- | -| `off` | Disabled | -- | -- | -- | -| `core` | Daily 3 AM | 0.75 | 3 | 2 | -| `rem` | Every 6 hours | 0.85 | 4 | 3 | -| `deep` | Every 12 hours | 0.80 | 3 | 3 | +| Mode | Cadence | minScore | minRecallCount | minUniqueQueries | recencyHalfLifeDays | +| ------ | -------------- | -------- | -------------- | ---------------- | ------------------- | +| `off` | Disabled | -- | -- | -- | -- | +| `core` | Daily 3 AM | 0.75 | 3 | 2 | 14 | +| `rem` | Every 6 hours | 0.85 | 4 | 3 | 14 | +| `deep` | Every 12 hours | 0.80 | 3 | 3 | 14 | ### Example diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d430a5eb8ac..a71b630b9dd 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -122,7 +122,7 @@ Text + native (when enabled): - `/model ` (alias: `/models`; or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/bash ` (host-only; alias for `! `; requires `commands.bash: true` + `tools.elevated` allowlists) -- `/dreaming [off|core|rem|deep|status|help]` (toggle dreaming mode or show status; see [Dreaming](/concepts/memory-dreaming)) +- `/dreaming [off|core|rem|deep|status|help]` (toggle dreaming mode or show status; see [Dreaming](/concepts/dreaming)) Text-only: diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index f89dec42974..c44e52329c8 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -42,6 +42,16 @@ "placeholder": "2", "help": "Minimum unique query count required for automatic promotion." }, + "dreaming.recencyHalfLifeDays": { + "label": "Recency Half-Life Days", + "placeholder": "14", + "help": "Days for the recency score to decay by half during dreaming ranking." + }, + "dreaming.maxAgeDays": { + "label": "Promotion Max Age Days", + "placeholder": "30", + "help": "Optional maximum daily-note age in days for automatic or manual promotion." + }, "dreaming.verboseLogging": { "label": "Dreaming Verbose Logging", "placeholder": "false", @@ -86,6 +96,14 @@ "type": "number", "minimum": 0 }, + "recencyHalfLifeDays": { + "type": "number", + "minimum": 0 + }, + "maxAgeDays": { + "type": "number", + "exclusiveMinimum": 0 + }, "verboseLogging": { "type": "boolean" } diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index c21ecdd5621..ec1b7f13388 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -121,7 +121,7 @@ function formatDreamingSummary(cfg: OpenClawConfig): string { return "off"; } const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : ""; - return `${dreaming.cron}${timezone} · limit=${dreaming.limit} · minScore=${dreaming.minScore} · minRecallCount=${dreaming.minRecallCount} · minUniqueQueries=${dreaming.minUniqueQueries}`; + return `${dreaming.cron}${timezone} · limit=${dreaming.limit} · minScore=${dreaming.minScore} · minRecallCount=${dreaming.minRecallCount} · minUniqueQueries=${dreaming.minUniqueQueries} · recencyHalfLifeDays=${dreaming.recencyHalfLifeDays} · maxAgeDays=${dreaming.maxAgeDays ?? "none"}`; } function formatAuditCounts(audit: ShortTermAuditSummary): string { @@ -927,6 +927,10 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) { run: async (manager) => { const status = manager.status(); const workspaceDir = status.workspaceDir?.trim(); + const dreaming = resolveShortTermPromotionDreamingConfig({ + pluginConfig: resolveMemoryPluginConfig(cfg), + cfg, + }); if (!workspaceDir) { defaultRuntime.error("Memory promote requires a resolvable workspace directory."); process.exitCode = 1; @@ -938,9 +942,11 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) { candidates = await rankShortTermPromotionCandidates({ workspaceDir, limit: opts.limit, - minScore: opts.minScore, - minRecallCount: opts.minRecallCount, - minUniqueQueries: opts.minUniqueQueries, + minScore: opts.minScore ?? dreaming.minScore, + minRecallCount: opts.minRecallCount ?? dreaming.minRecallCount, + minUniqueQueries: opts.minUniqueQueries ?? dreaming.minUniqueQueries, + recencyHalfLifeDays: dreaming.recencyHalfLifeDays, + maxAgeDays: dreaming.maxAgeDays, includePromoted: Boolean(opts.includePromoted), }); } catch (err) { @@ -952,17 +958,14 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) { let applyResult: Awaited> | undefined; if (opts.apply) { try { - const dreaming = resolveShortTermPromotionDreamingConfig({ - pluginConfig: resolveMemoryPluginConfig(cfg), - cfg, - }); applyResult = await applyShortTermPromotions({ workspaceDir, candidates, limit: opts.limit, - minScore: opts.minScore, - minRecallCount: opts.minRecallCount, - minUniqueQueries: opts.minUniqueQueries, + minScore: opts.minScore ?? dreaming.minScore, + minRecallCount: opts.minRecallCount ?? dreaming.minRecallCount, + minUniqueQueries: opts.minUniqueQueries ?? dreaming.minUniqueQueries, + maxAgeDays: dreaming.maxAgeDays, timezone: dreaming.timezone, }); } catch (err) { diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index c85bc374283..84d8cc20aee 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -120,6 +120,8 @@ describe("memory-core /dreaming command", () => { dreaming: { mode: "deep", timezone: "America/Los_Angeles", + recencyHalfLifeDays: 21, + maxAgeDays: 45, }, }, }, @@ -132,6 +134,7 @@ describe("memory-core /dreaming command", () => { expect(result.text).toContain("Dreaming status:"); expect(result.text).toContain("- mode: deep"); expect(result.text).toContain("- cadence: 0 */12 * * * (America/Los_Angeles)"); + expect(result.text).toContain("- aging: recencyHalfLifeDays=21, maxAgeDays=45"); expect(result.text).toContain("- verboseLogging: off"); expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); }); diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index c1c4d9cc0ee..134cbc678e5 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -83,7 +83,8 @@ function formatModeGuideLine(mode: DreamingMode): string { return ( `- ${mode}: cadence=${resolved.cron}; ` + `minScore=${resolved.minScore}, minRecallCount=${resolved.minRecallCount}, ` + - `minUniqueQueries=${resolved.minUniqueQueries}.` + `minUniqueQueries=${resolved.minUniqueQueries}, recencyHalfLifeDays=${resolved.recencyHalfLifeDays}, ` + + `maxAgeDays=${resolved.maxAgeDays ?? "none"}.` ); } @@ -107,6 +108,7 @@ function formatStatus(cfg: OpenClawConfig): string { `- cadence: ${cadence}${timezone}`, `- limit: ${resolved.limit}`, `- thresholds: minScore=${resolved.minScore}, minRecallCount=${resolved.minRecallCount}, minUniqueQueries=${resolved.minUniqueQueries}`, + `- aging: recencyHalfLifeDays=${resolved.recencyHalfLifeDays}, maxAgeDays=${resolved.maxAgeDays ?? "none"}`, `- verboseLogging: ${resolved.verboseLogging ? "on" : "off"}`, ].join("\n"); } diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index 3a99dc3496e..54537dcba26 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -127,6 +127,7 @@ describe("short-term dreaming config", () => { minScore: constants.DEFAULT_DREAMING_MIN_SCORE, minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT, minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES, + recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS, verboseLogging: false, }); }); @@ -142,6 +143,8 @@ describe("short-term dreaming config", () => { minScore: 0.4, minRecallCount: 2, minUniqueQueries: 3, + recencyHalfLifeDays: 21, + maxAgeDays: 30, verboseLogging: true, }, }, @@ -154,6 +157,8 @@ describe("short-term dreaming config", () => { minScore: 0.4, minRecallCount: 2, minUniqueQueries: 3, + recencyHalfLifeDays: 21, + maxAgeDays: 30, verboseLogging: true, }); }); @@ -168,6 +173,8 @@ describe("short-term dreaming config", () => { minScore: "0.6", minRecallCount: "2", minUniqueQueries: "3", + recencyHalfLifeDays: "9", + maxAgeDays: "45", }, }, }); @@ -178,6 +185,8 @@ describe("short-term dreaming config", () => { minScore: 0.6, minRecallCount: 2, minUniqueQueries: 3, + recencyHalfLifeDays: 9, + maxAgeDays: 45, verboseLogging: false, }); }); @@ -191,6 +200,8 @@ describe("short-term dreaming config", () => { minScore: "", minRecallCount: " ", minUniqueQueries: "", + recencyHalfLifeDays: "", + maxAgeDays: " ", }, }, }); @@ -201,6 +212,7 @@ describe("short-term dreaming config", () => { minScore: constants.DREAMING_PRESET_DEFAULTS.deep.minScore, minRecallCount: constants.DREAMING_PRESET_DEFAULTS.deep.minRecallCount, minUniqueQueries: constants.DREAMING_PRESET_DEFAULTS.deep.minUniqueQueries, + recencyHalfLifeDays: constants.DREAMING_PRESET_DEFAULTS.deep.recencyHalfLifeDays, verboseLogging: false, }); }); @@ -247,6 +259,8 @@ describe("short-term dreaming config", () => { minScore: -0.2, minRecallCount: -2, minUniqueQueries: -4, + recencyHalfLifeDays: -10, + maxAgeDays: -5, }, }, }); @@ -255,7 +269,9 @@ describe("short-term dreaming config", () => { minScore: constants.DREAMING_PRESET_DEFAULTS.rem.minScore, minRecallCount: constants.DREAMING_PRESET_DEFAULTS.rem.minRecallCount, minUniqueQueries: constants.DREAMING_PRESET_DEFAULTS.rem.minUniqueQueries, + recencyHalfLifeDays: constants.DREAMING_PRESET_DEFAULTS.rem.recencyHalfLifeDays, }); + expect(resolved.maxAgeDays).toBeUndefined(); }); it("keeps dreaming disabled when mode is off", () => { diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index cd35c0ed50c..4b132804d18 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -5,6 +5,7 @@ import { DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT, DEFAULT_MEMORY_DREAMING_MIN_SCORE, DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES, + DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS, DEFAULT_MEMORY_DREAMING_MODE, DEFAULT_MEMORY_DREAMING_PRESET, MEMORY_DREAMING_PRESET_DEFAULTS, @@ -80,6 +81,8 @@ export type ShortTermPromotionDreamingConfig = { minScore: number; minRecallCount: number; minUniqueQueries: number; + recencyHalfLifeDays: number; + maxAgeDays?: number; verboseLogging: boolean; }; @@ -130,7 +133,7 @@ function formatRepairSummary(repair: { } function resolveManagedCronDescription(config: ShortTermPromotionDreamingConfig): string { - return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}).`; + return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}, recencyHalfLifeDays=${config.recencyHalfLifeDays}, maxAgeDays=${config.maxAgeDays ?? "none"}).`; } function buildManagedDreamingCronJob( @@ -269,6 +272,8 @@ export function resolveShortTermPromotionDreamingConfig(params: { minScore: resolved.minScore, minRecallCount: resolved.minRecallCount, minUniqueQueries: resolved.minUniqueQueries, + recencyHalfLifeDays: resolved.recencyHalfLifeDays, + ...(typeof resolved.maxAgeDays === "number" ? { maxAgeDays: resolved.maxAgeDays } : {}), verboseLogging: resolved.verboseLogging, }; } @@ -387,7 +392,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { if (params.config.verboseLogging) { params.logger.info( - `memory-core: dreaming verbose enabled (cron=${params.config.cron}, limit=${params.config.limit}, minScore=${params.config.minScore.toFixed(3)}, minRecallCount=${params.config.minRecallCount}, minUniqueQueries=${params.config.minUniqueQueries}, workspaces=${workspaces.length}).`, + `memory-core: dreaming verbose enabled (cron=${params.config.cron}, limit=${params.config.limit}, minScore=${params.config.minScore.toFixed(3)}, minRecallCount=${params.config.minRecallCount}, minUniqueQueries=${params.config.minUniqueQueries}, recencyHalfLifeDays=${params.config.recencyHalfLifeDays}, maxAgeDays=${params.config.maxAgeDays ?? "none"}, workspaces=${workspaces.length}).`, ); } @@ -408,6 +413,8 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { minScore: params.config.minScore, minRecallCount: params.config.minRecallCount, minUniqueQueries: params.config.minUniqueQueries, + recencyHalfLifeDays: params.config.recencyHalfLifeDays, + maxAgeDays: params.config.maxAgeDays, }); totalCandidates += candidates.length; if (params.config.verboseLogging) { @@ -431,6 +438,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { minScore: params.config.minScore, minRecallCount: params.config.minRecallCount, minUniqueQueries: params.config.minUniqueQueries, + maxAgeDays: params.config.maxAgeDays, timezone: params.config.timezone, }); totalApplied += applied.applied; @@ -528,6 +536,7 @@ export const __testing = { DEFAULT_DREAMING_MIN_SCORE: DEFAULT_MEMORY_DREAMING_MIN_SCORE, DEFAULT_DREAMING_MIN_RECALL_COUNT: DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT, DEFAULT_DREAMING_MIN_UNIQUE_QUERIES: DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES, + DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS: DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS, DREAMING_PRESET_DEFAULTS: MEMORY_DREAMING_PRESET_DEFAULTS, }, }; diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 3b3bafdd440..c166f480936 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -206,6 +206,80 @@ describe("short-term promotion", () => { }); }); + it("lets recency half-life tune the temporal score", async () => { + await withTempWorkspace(async (workspaceDir) => { + await recordShortTermRecalls({ + workspaceDir, + query: "glacier retention", + nowMs: Date.parse("2026-04-01T10:00:00.000Z"), + results: [ + { + path: "memory/2026-04-01.md", + startLine: 1, + endLine: 2, + score: 0.92, + snippet: "Move backups to S3 Glacier.", + source: "memory", + }, + ], + }); + + const slowerDecay = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-04-15T10:00:00.000Z"), + recencyHalfLifeDays: 14, + }); + const fasterDecay = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-04-15T10:00:00.000Z"), + recencyHalfLifeDays: 7, + }); + + expect(slowerDecay).toHaveLength(1); + expect(fasterDecay).toHaveLength(1); + expect(slowerDecay[0]?.components.recency).toBeCloseTo(0.5, 3); + expect(fasterDecay[0]?.components.recency).toBeCloseTo(0.25, 3); + expect(slowerDecay[0]!.score).toBeGreaterThan(fasterDecay[0]!.score); + }); + }); + + it("filters out candidates older than maxAgeDays during ranking", async () => { + await withTempWorkspace(async (workspaceDir) => { + await recordShortTermRecalls({ + workspaceDir, + query: "old note", + nowMs: Date.parse("2026-04-01T10:00:00.000Z"), + results: [ + { + path: "memory/2026-04-01.md", + startLine: 1, + endLine: 2, + score: 0.92, + snippet: "Move backups to S3 Glacier.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-04-15T10:00:00.000Z"), + maxAgeDays: 7, + }); + + expect(ranked).toHaveLength(0); + }); + }); + it("treats negative threshold overrides as invalid and keeps defaults", async () => { await withTempWorkspace(async (workspaceDir) => { await recordShortTermRecalls({ @@ -271,6 +345,53 @@ describe("short-term promotion", () => { }); }); + it("skips direct candidates that exceed maxAgeDays during apply", async () => { + await withTempWorkspace(async (workspaceDir) => { + const applied = await applyShortTermPromotions({ + workspaceDir, + maxAgeDays: 7, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + candidates: [ + { + key: "memory:memory/2026-04-01.md:1:1", + path: "memory/2026-04-01.md", + startLine: 1, + endLine: 1, + source: "memory", + snippet: "Expired short-term note.", + recallCount: 3, + avgScore: 0.95, + maxScore: 0.95, + uniqueQueries: 2, + firstRecalledAt: "2026-04-01T00:00:00.000Z", + lastRecalledAt: "2026-04-02T00:00:00.000Z", + ageDays: 10, + score: 0.95, + recallDays: ["2026-04-01", "2026-04-02"], + conceptTags: ["expired"], + components: { + frequency: 1, + relevance: 1, + diversity: 1, + recency: 1, + consolidation: 1, + conceptual: 1, + }, + }, + ], + }); + + expect(applied.applied).toBe(0); + await expect( + fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); + it("applies promotion candidates to MEMORY.md and marks them promoted", async () => { await withTempWorkspace(async (workspaceDir) => { await writeDailyMemoryNote(workspaceDir, "2026-04-01", [ diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index ee8c7bb9cf1..2f9a6588d0f 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -146,6 +146,7 @@ export type RankShortTermPromotionOptions = { minScore?: number; minRecallCount?: number; minUniqueQueries?: number; + maxAgeDays?: number; includePromoted?: boolean; recencyHalfLifeDays?: number; weights?: Partial; @@ -159,6 +160,7 @@ export type ApplyShortTermPromotionsOptions = { minScore?: number; minRecallCount?: number; minUniqueQueries?: number; + maxAgeDays?: number; nowMs?: number; timezone?: string; }; @@ -651,6 +653,7 @@ export async function rankShortTermPromotionCandidates( options.minUniqueQueries, DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES, ); + const maxAgeDays = toFiniteNonNegativeInt(options.maxAgeDays, -1); const includePromoted = Boolean(options.includePromoted); const halfLifeDays = toFinitePositive( options.recencyHalfLifeDays, @@ -686,6 +689,9 @@ export async function rankShortTermPromotionCandidates( const ageDays = Number.isFinite(lastRecalledAtMs) ? Math.max(0, (nowMs - lastRecalledAtMs) / DAY_MS) : 0; + if (maxAgeDays >= 0 && ageDays > maxAgeDays) { + continue; + } const recency = clampScore(calculateRecencyComponent(ageDays, halfLifeDays)); const recallDays = entry.recallDays ?? []; const conceptTags = entry.conceptTags ?? []; @@ -946,6 +952,7 @@ export async function applyShortTermPromotions( options.minUniqueQueries, DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES, ); + const maxAgeDays = toFiniteNonNegativeInt(options.maxAgeDays, -1); const memoryPath = path.join(workspaceDir, "MEMORY.md"); return await withShortTermLock(workspaceDir, async () => { @@ -964,6 +971,9 @@ export async function applyShortTermPromotions( if (candidate.uniqueQueries < minUniqueQueries) { return false; } + if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) { + return false; + } const latest = store.entries[candidate.key]; if (latest?.promotedAt) { return false; diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 36b0272c7b1..150a1b33586 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -238,6 +238,8 @@ describe("doctor.memory.status", () => { dreaming: { mode: "rem", cron: "0 */4 * * *", + recencyHalfLifeDays: 21, + maxAgeDays: 30, }, }, }, @@ -287,6 +289,8 @@ describe("doctor.memory.status", () => { enabled: true, frequency: "0 */4 * * *", timezone: "America/Los_Angeles", + recencyHalfLifeDays: 21, + maxAgeDays: 30, shortTermCount: 1, promotedTotal: 3, promotedToday: 2, diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index eb8f05d388b..12ddd67ac89 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -28,6 +28,8 @@ type DoctorMemoryDreamingPayload = { minScore: number; minRecallCount: number; minUniqueQueries: number; + recencyHalfLifeDays: number; + maxAgeDays?: number; shortTermCount: number; promotedTotal: number; promotedToday: number; @@ -89,6 +91,8 @@ function resolveDreamingConfig( minScore: resolved.minScore, minRecallCount: resolved.minRecallCount, minUniqueQueries: resolved.minUniqueQueries, + recencyHalfLifeDays: resolved.recencyHalfLifeDays, + ...(typeof resolved.maxAgeDays === "number" ? { maxAgeDays: resolved.maxAgeDays } : {}), }; } diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts index c0939b90df7..b9da1a84807 100644 --- a/src/memory-host-sdk/dreaming.test.ts +++ b/src/memory-host-sdk/dreaming.test.ts @@ -39,6 +39,8 @@ describe("memory dreaming host helpers", () => { minScore: "0.9", minRecallCount: "4", minUniqueQueries: "2", + recencyHalfLifeDays: "21", + maxAgeDays: "30", verboseLogging: "true", }, }, @@ -53,6 +55,8 @@ describe("memory dreaming host helpers", () => { minScore: 0.9, minRecallCount: 4, minUniqueQueries: 2, + recencyHalfLifeDays: 21, + maxAgeDays: 30, verboseLogging: true, }); }); @@ -80,6 +84,8 @@ describe("memory dreaming host helpers", () => { expect(resolved.timezone).toBe("America/Los_Angeles"); expect(resolved.limit).toBe(10); expect(resolved.minScore).toBe(0.75); + expect(resolved.recencyHalfLifeDays).toBe(14); + expect(resolved.maxAgeDays).toBeUndefined(); }); it("dedupes shared workspaces and skips agents without memory search", () => { diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index 63677edc552..fb7f429607b 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -8,6 +8,7 @@ export const DEFAULT_MEMORY_DREAMING_LIMIT = 10; export const DEFAULT_MEMORY_DREAMING_MIN_SCORE = 0.75; export const DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT = 3; export const DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES = 2; +export const DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS = 14; export const DEFAULT_MEMORY_DREAMING_MODE = "off"; export const DEFAULT_MEMORY_DREAMING_PRESET = "core"; @@ -23,6 +24,8 @@ export type MemoryDreamingConfig = { minScore: number; minRecallCount: number; minUniqueQueries: number; + recencyHalfLifeDays: number; + maxAgeDays?: number; verboseLogging: boolean; }; @@ -39,6 +42,7 @@ export const MEMORY_DREAMING_PRESET_DEFAULTS: Record< minScore: number; minRecallCount: number; minUniqueQueries: number; + recencyHalfLifeDays: number; } > = { core: { @@ -47,6 +51,7 @@ export const MEMORY_DREAMING_PRESET_DEFAULTS: Record< minScore: DEFAULT_MEMORY_DREAMING_MIN_SCORE, minRecallCount: DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT, minUniqueQueries: DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES, + recencyHalfLifeDays: DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS, }, deep: { cron: "0 */12 * * *", @@ -54,6 +59,7 @@ export const MEMORY_DREAMING_PRESET_DEFAULTS: Record< minScore: 0.8, minRecallCount: 3, minUniqueQueries: 3, + recencyHalfLifeDays: DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS, }, rem: { cron: "0 */6 * * *", @@ -61,6 +67,7 @@ export const MEMORY_DREAMING_PRESET_DEFAULTS: Record< minScore: 0.85, minRecallCount: 4, minUniqueQueries: 3, + recencyHalfLifeDays: DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS, }, }; @@ -105,6 +112,24 @@ function normalizeScore(value: unknown, fallback: number): number { return num; } +function normalizeOptionalPositiveInt(value: unknown): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === "string" && value.trim().length === 0) { + return undefined; + } + const num = typeof value === "string" ? Number(value.trim()) : Number(value); + if (!Number.isFinite(num)) { + return undefined; + } + const floored = Math.floor(num); + if (floored <= 0) { + return undefined; + } + return floored; +} + function normalizeBoolean(value: unknown, fallback: boolean): boolean { if (typeof value === "boolean") { return value; @@ -172,6 +197,7 @@ export function resolveMemoryDreamingConfig(params: { const timezone = normalizeTrimmedString(dreaming?.timezone) ?? normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone); + const maxAgeDays = normalizeOptionalPositiveInt(dreaming?.maxAgeDays); return { mode, enabled, @@ -187,6 +213,11 @@ export function resolveMemoryDreamingConfig(params: { dreaming?.minUniqueQueries, defaults.minUniqueQueries, ), + recencyHalfLifeDays: normalizeNonNegativeInt( + dreaming?.recencyHalfLifeDays, + defaults.recencyHalfLifeDays, + ), + ...(typeof maxAgeDays === "number" ? { maxAgeDays } : {}), verboseLogging: normalizeBoolean(dreaming?.verboseLogging, false), }; }