feat(memory-core): add dreaming aging controls

This commit is contained in:
Peter Steinberger
2026-04-05 15:58:35 +01:00
parent c1bba98e88
commit 6e3155ca84
20 changed files with 300 additions and 57 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -1078,7 +1078,7 @@
"concepts/memory-qmd",
"concepts/memory-honcho",
"concepts/memory-search",
"concepts/memory-dreaming"
"concepts/dreaming"
]
},
"concepts/compaction"

View File

@@ -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.

View File

@@ -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

View File

@@ -122,7 +122,7 @@ Text + native (when enabled):
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; 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:

View File

@@ -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"
}

View File

@@ -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<ReturnType<typeof applyShortTermPromotions>> | 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) {

View File

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

View File

@@ -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");
}

View File

@@ -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", () => {

View File

@@ -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,
},
};

View File

@@ -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", [

View File

@@ -146,6 +146,7 @@ export type RankShortTermPromotionOptions = {
minScore?: number;
minRecallCount?: number;
minUniqueQueries?: number;
maxAgeDays?: number;
includePromoted?: boolean;
recencyHalfLifeDays?: number;
weights?: Partial<PromotionWeights>;
@@ -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;

View File

@@ -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,

View File

@@ -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 } : {}),
};
}

View File

@@ -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", () => {

View File

@@ -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),
};
}