mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat(memory-core): add dreaming aging controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -1078,7 +1078,7 @@
|
||||
"concepts/memory-qmd",
|
||||
"concepts/memory-honcho",
|
||||
"concepts/memory-search",
|
||||
"concepts/memory-dreaming"
|
||||
"concepts/dreaming"
|
||||
]
|
||||
},
|
||||
"concepts/compaction"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user