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

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