fix(memory-core): align dreaming promotion

This commit is contained in:
Peter Steinberger
2026-04-05 15:45:54 +01:00
parent 40ffada812
commit f7670bde7e
14 changed files with 1359 additions and 361 deletions

View File

@@ -855,6 +855,10 @@ export async function runMemorySearch(
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory search");
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
const agentId = resolveAgent(cfg, opts.agent);
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryPluginConfig(cfg),
cfg,
});
await withMemoryManagerForAgent({
cfg,
agentId,
@@ -881,6 +885,7 @@ export async function runMemorySearch(
workspaceDir,
query,
results,
timezone: dreaming.timezone,
}).catch(() => {
// Recall tracking is best-effort and must not block normal search results.
});
@@ -947,6 +952,10 @@ 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,
@@ -954,6 +963,7 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
minScore: opts.minScore,
minRecallCount: opts.minRecallCount,
minUniqueQueries: opts.minUniqueQueries,
timezone: dreaming.timezone,
});
} catch (err) {
defaultRuntime.error(`Memory promote apply failed: ${formatErrorMessage(err)}`);

View File

@@ -176,6 +176,16 @@ describe("memory cli", () => {
}
}
async function writeDailyMemoryNote(
workspaceDir: string,
date: string,
lines: string[],
): Promise<void> {
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
await fs.mkdir(path.dirname(notePath), { recursive: true });
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
}
async function expectCloseFailureAfterCommand(params: {
args: string[];
manager: Record<string, unknown>;
@@ -873,6 +883,22 @@ describe("memory cli", () => {
it("applies top promote candidates into MEMORY.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"line 1",
"line 2",
"line 3",
"line 4",
"line 5",
"line 6",
"line 7",
"line 8",
"line 9",
"Gateway host uses local mode and binds loopback port 18789",
"Keep agent gateway local",
"Expose healthcheck only on loopback",
"Monitor restart policy",
"Review proxy config",
]);
await recordShortTermRecalls({
workspaceDir,
query: "network setup",
@@ -909,7 +935,7 @@ describe("memory cli", () => {
const memoryPath = path.join(workspaceDir, "MEMORY.md");
const memoryText = await fs.readFile(memoryPath, "utf-8");
expect(memoryText).toContain("Promoted From Short-Term Memory");
expect(memoryText).toContain("memory/2026-04-01.md:10-14");
expect(memoryText).toContain("memory/2026-04-01.md:10-10");
expect(log).toHaveBeenCalledWith(expect.stringContaining("Promoted 1 candidate(s) to"));
expect(close).toHaveBeenCalled();
});

View File

@@ -26,6 +26,16 @@ function createLogger() {
};
}
async function writeDailyMemoryNote(
workspaceDir: string,
date: string,
lines: string[],
): Promise<void> {
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
await fs.mkdir(path.dirname(notePath), { recursive: true });
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
}
function createCronHarness(
initialJobs: CronJobLike[] = [],
opts?: { removeResult?: "boolean" | "unknown"; removeThrowsForIds?: string[] },
@@ -504,6 +514,7 @@ describe("short-term dreaming trigger", () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-"));
tempDirs.push(workspaceDir);
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
await recordShortTermRecalls({
workspaceDir,
@@ -545,6 +556,10 @@ describe("short-term dreaming trigger", () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-strict-"));
tempDirs.push(workspaceDir);
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [
"Move backups to S3 Glacier.",
"Retain quarterly snapshots.",
]);
await recordShortTermRecalls({
workspaceDir,
@@ -646,6 +661,10 @@ describe("short-term dreaming trigger", () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-repair-"));
tempDirs.push(workspaceDir);
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [
"Move backups to S3 Glacier and sync router failover notes.",
"Keep router recovery docs current.",
]);
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
@@ -722,6 +741,7 @@ describe("short-term dreaming trigger", () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-verbose-"));
tempDirs.push(workspaceDir);
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
await recordShortTermRecalls({
workspaceDir,
@@ -765,4 +785,89 @@ describe("short-term dreaming trigger", () => {
expect.stringContaining("memory-core: dreaming applied details"),
);
});
it("fans out one dreaming run across configured agent workspaces", async () => {
const logger = createLogger();
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-multi-"));
tempDirs.push(workspaceRoot);
const alphaWorkspace = path.join(workspaceRoot, "alpha");
const betaWorkspace = path.join(workspaceRoot, "beta");
await writeDailyMemoryNote(alphaWorkspace, "2026-04-02", ["Alpha backup note."]);
await writeDailyMemoryNote(betaWorkspace, "2026-04-02", ["Beta router note."]);
await recordShortTermRecalls({
workspaceDir: alphaWorkspace,
query: "alpha backup",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Alpha backup note.",
source: "memory",
},
],
});
await recordShortTermRecalls({
workspaceDir: betaWorkspace,
query: "beta router",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Beta router note.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir: alphaWorkspace,
cfg: {
agents: {
defaults: {
memorySearch: {
enabled: true,
},
},
list: [
{
id: "alpha",
workspace: alphaWorkspace,
},
{
id: "beta",
workspace: betaWorkspace,
},
],
},
} as OpenClawConfig,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
verboseLogging: false,
},
logger,
});
expect(result?.handled).toBe(true);
expect(await fs.readFile(path.join(alphaWorkspace, "MEMORY.md"), "utf-8")).toContain(
"Alpha backup note.",
);
expect(await fs.readFile(path.join(betaWorkspace, "MEMORY.md"), "utf-8")).toContain(
"Beta router note.",
);
expect(logger.info).toHaveBeenCalledWith(
"memory-core: dreaming promotion complete (workspaces=2, candidates=2, applied=2, failed=0).",
);
});
});

View File

@@ -1,9 +1,19 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_MEMORY_DREAMING_LIMIT,
DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
DEFAULT_MEMORY_DREAMING_MIN_SCORE,
DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
DEFAULT_MEMORY_DREAMING_MODE,
DEFAULT_MEMORY_DREAMING_PRESET,
MEMORY_DREAMING_PRESET_DEFAULTS,
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-core-host-status";
import {
applyShortTermPromotions,
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
DEFAULT_PROMOTION_MIN_SCORE,
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
repairShortTermPromotionArtifacts,
rankShortTermPromotionCandidates,
} from "./short-term-promotion.js";
@@ -11,49 +21,6 @@ import {
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
const DEFAULT_DREAMING_CRON_EXPR = "0 3 * * *";
const DEFAULT_DREAMING_LIMIT = 10;
const DEFAULT_DREAMING_MIN_SCORE = DEFAULT_PROMOTION_MIN_SCORE;
const DEFAULT_DREAMING_MIN_RECALL_COUNT = DEFAULT_PROMOTION_MIN_RECALL_COUNT;
const DEFAULT_DREAMING_MIN_UNIQUE_QUERIES = DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES;
const DEFAULT_DREAMING_MODE = "off";
const DEFAULT_DREAMING_PRESET = "core";
type DreamingPreset = "core" | "deep" | "rem";
type DreamingMode = DreamingPreset | "off";
const DREAMING_PRESET_DEFAULTS: Record<
DreamingPreset,
{
cron: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
}
> = {
core: {
cron: DEFAULT_DREAMING_CRON_EXPR,
limit: DEFAULT_DREAMING_LIMIT,
minScore: DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
deep: {
cron: "0 */12 * * *",
limit: DEFAULT_DREAMING_LIMIT,
minScore: 0.8,
minRecallCount: 3,
minUniqueQueries: 3,
},
rem: {
cron: "0 */6 * * *",
limit: DEFAULT_DREAMING_LIMIT,
minScore: 0.85,
minRecallCount: 4,
minUniqueQueries: 3,
},
};
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
@@ -138,64 +105,6 @@ function normalizeTrimmedString(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeDreamingMode(value: unknown): DreamingMode {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (
normalized === "off" ||
normalized === "core" ||
normalized === "deep" ||
normalized === "rem"
) {
return normalized;
}
return DEFAULT_DREAMING_MODE;
}
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value === "string" && value.trim().length === 0) {
return fallback;
}
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
if (!Number.isFinite(num)) {
return fallback;
}
const floored = Math.floor(num);
if (floored < 0) {
return fallback;
}
return floored;
}
function normalizeScore(value: unknown, fallback: number): number {
if (typeof value === "string" && value.trim().length === 0) {
return fallback;
}
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
if (!Number.isFinite(num)) {
return fallback;
}
if (num < 0 || num > 1) {
return fallback;
}
return num;
}
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") {
return true;
}
if (normalized === "false") {
return false;
}
}
return fallback;
}
function formatErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
@@ -203,12 +112,6 @@ function formatErrorMessage(err: unknown): string {
return String(err);
}
function resolveTimezoneFallback(cfg: OpenClawConfig | undefined): string | undefined {
const agents = asRecord(cfg?.agents);
const defaults = asRecord(agents?.defaults);
return normalizeTrimmedString(defaults?.userTimezone);
}
function formatRepairSummary(repair: {
rewroteStore: boolean;
removedInvalidEntries: number;
@@ -357,37 +260,16 @@ export function resolveShortTermPromotionDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): ShortTermPromotionDreamingConfig {
const dreaming = asRecord(params.pluginConfig?.dreaming);
const mode = normalizeDreamingMode(dreaming?.mode);
const enabled = mode !== "off";
const thresholdPreset: DreamingPreset = mode === "off" ? DEFAULT_DREAMING_PRESET : mode;
const thresholdDefaults = DREAMING_PRESET_DEFAULTS[thresholdPreset];
const cron =
normalizeTrimmedString(dreaming?.cron) ??
normalizeTrimmedString(dreaming?.frequency) ??
thresholdDefaults.cron;
const timezone =
normalizeTrimmedString(dreaming?.timezone) ?? resolveTimezoneFallback(params.cfg);
const limit = normalizeNonNegativeInt(dreaming?.limit, thresholdDefaults.limit);
const minScore = normalizeScore(dreaming?.minScore, thresholdDefaults.minScore);
const minRecallCount = normalizeNonNegativeInt(
dreaming?.minRecallCount,
thresholdDefaults.minRecallCount,
);
const minUniqueQueries = normalizeNonNegativeInt(
dreaming?.minUniqueQueries,
thresholdDefaults.minUniqueQueries,
);
const resolved = resolveMemoryDreamingConfig(params);
return {
enabled,
cron,
...(timezone ? { timezone } : {}),
limit,
minScore,
minRecallCount,
minUniqueQueries,
verboseLogging: normalizeBoolean(dreaming?.verboseLogging, false),
enabled: resolved.enabled,
cron: resolved.cron,
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
limit: resolved.limit,
minScore: resolved.minScore,
minRecallCount: resolved.minRecallCount,
minUniqueQueries: resolved.minUniqueQueries,
verboseLogging: resolved.verboseLogging,
};
}
@@ -463,6 +345,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
cleanedBody: string;
trigger?: string;
workspaceDir?: string;
cfg?: OpenClawConfig;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
}): Promise<{ handled: true; reason: string } | undefined> {
@@ -476,10 +359,24 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
return { handled: true, reason: "memory-core: short-term dreaming disabled" };
}
const workspaceDir = normalizeTrimmedString(params.workspaceDir);
if (!workspaceDir) {
const workspaceCandidates = params.cfg
? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir)
: [];
const seenWorkspaces = new Set<string>();
const workspaces = workspaceCandidates.filter((workspaceDir) => {
if (seenWorkspaces.has(workspaceDir)) {
return false;
}
seenWorkspaces.add(workspaceDir);
return true;
});
const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir);
if (workspaces.length === 0 && fallbackWorkspaceDir) {
workspaces.push(fallbackWorkspaceDir);
}
if (workspaces.length === 0) {
params.logger.warn(
"memory-core: dreaming promotion skipped because workspaceDir is unavailable.",
"memory-core: dreaming promotion skipped because no memory workspace is available.",
);
return { handled: true, reason: "memory-core: short-term dreaming missing workspace" };
}
@@ -488,64 +385,80 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
return { handled: true, reason: "memory-core: short-term dreaming disabled by limit" };
}
try {
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}).`,
);
}
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (repair.changed) {
params.logger.info(
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}).`,
);
}
const candidates = await rankShortTermPromotionCandidates({
workspaceDir,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
minUniqueQueries: params.config.minUniqueQueries,
});
if (params.config.verboseLogging) {
const candidateSummary =
candidates.length > 0
? candidates
.map(
(candidate) =>
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} queries=${candidate.uniqueQueries} components={freq=${candidate.components.frequency.toFixed(3)},rel=${candidate.components.relevance.toFixed(3)},div=${candidate.components.diversity.toFixed(3)},rec=${candidate.components.recency.toFixed(3)},cons=${candidate.components.consolidation.toFixed(3)},concept=${candidate.components.conceptual.toFixed(3)}}`,
)
.join(" | ")
: "none";
params.logger.info(`memory-core: dreaming candidate details ${candidateSummary}`);
}
const applied = await applyShortTermPromotions({
workspaceDir,
candidates,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
minUniqueQueries: params.config.minUniqueQueries,
});
if (params.config.verboseLogging) {
const appliedSummary =
applied.appliedCandidates.length > 0
? applied.appliedCandidates
.map(
(candidate) =>
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount}`,
)
.join(" | ")
: "none";
params.logger.info(`memory-core: dreaming applied details ${appliedSummary}`);
}
if (params.config.verboseLogging) {
params.logger.info(
`memory-core: dreaming promotion complete (candidates=${candidates.length}, applied=${applied.applied}).`,
`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}).`,
);
} catch (err) {
params.logger.error(`memory-core: dreaming promotion failed: ${formatErrorMessage(err)}`);
}
let totalCandidates = 0;
let totalApplied = 0;
let failedWorkspaces = 0;
for (const workspaceDir of workspaces) {
try {
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (repair.changed) {
params.logger.info(
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}) [workspace=${workspaceDir}].`,
);
}
const candidates = await rankShortTermPromotionCandidates({
workspaceDir,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
minUniqueQueries: params.config.minUniqueQueries,
});
totalCandidates += candidates.length;
if (params.config.verboseLogging) {
const candidateSummary =
candidates.length > 0
? candidates
.map(
(candidate) =>
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} queries=${candidate.uniqueQueries} components={freq=${candidate.components.frequency.toFixed(3)},rel=${candidate.components.relevance.toFixed(3)},div=${candidate.components.diversity.toFixed(3)},rec=${candidate.components.recency.toFixed(3)},cons=${candidate.components.consolidation.toFixed(3)},concept=${candidate.components.conceptual.toFixed(3)}}`,
)
.join(" | ")
: "none";
params.logger.info(
`memory-core: dreaming candidate details [workspace=${workspaceDir}] ${candidateSummary}`,
);
}
const applied = await applyShortTermPromotions({
workspaceDir,
candidates,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
minUniqueQueries: params.config.minUniqueQueries,
timezone: params.config.timezone,
});
totalApplied += applied.applied;
if (params.config.verboseLogging) {
const appliedSummary =
applied.appliedCandidates.length > 0
? applied.appliedCandidates
.map(
(candidate) =>
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount}`,
)
.join(" | ")
: "none";
params.logger.info(
`memory-core: dreaming applied details [workspace=${workspaceDir}] ${appliedSummary}`,
);
}
} catch (err) {
failedWorkspaces += 1;
params.logger.error(
`memory-core: dreaming promotion failed for workspace ${workspaceDir}: ${formatErrorMessage(err)}`,
);
}
}
params.logger.info(
`memory-core: dreaming promotion complete (workspaces=${workspaces.length}, candidates=${totalCandidates}, applied=${totalApplied}, failed=${failedWorkspaces}).`,
);
return { handled: true, reason: "memory-core: short-term dreaming processed" };
}
@@ -555,7 +468,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
async (event: unknown) => {
try {
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig: api.pluginConfig,
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
cfg: api.config,
});
const cron = resolveCronServiceFromStartupEvent(event);
@@ -581,13 +494,14 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
api.on("before_agent_reply", async (event, ctx) => {
try {
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig: api.pluginConfig,
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
cfg: api.config,
});
return await runShortTermDreamingPromotionIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: api.config,
config,
logger: api.logger,
});
@@ -607,13 +521,13 @@ export const __testing = {
MANAGED_DREAMING_CRON_NAME,
MANAGED_DREAMING_CRON_TAG,
DREAMING_SYSTEM_EVENT_TEXT,
DEFAULT_DREAMING_MODE,
DEFAULT_DREAMING_PRESET,
DEFAULT_DREAMING_CRON_EXPR,
DEFAULT_DREAMING_LIMIT,
DEFAULT_DREAMING_MIN_SCORE,
DEFAULT_DREAMING_MIN_RECALL_COUNT,
DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
DREAMING_PRESET_DEFAULTS,
DEFAULT_DREAMING_MODE: DEFAULT_MEMORY_DREAMING_MODE,
DEFAULT_DREAMING_PRESET: DEFAULT_MEMORY_DREAMING_PRESET,
DEFAULT_DREAMING_CRON_EXPR: DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_DREAMING_LIMIT: DEFAULT_MEMORY_DREAMING_LIMIT,
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,
DREAMING_PRESET_DEFAULTS: MEMORY_DREAMING_PRESET_DEFAULTS,
},
};

View File

@@ -24,6 +24,17 @@ describe("short-term promotion", () => {
}
}
async function writeDailyMemoryNote(
workspaceDir: string,
date: string,
lines: string[],
): Promise<string> {
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
await fs.mkdir(path.dirname(notePath), { recursive: true });
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
return notePath;
}
it("detects short-term daily memory paths", () => {
expect(isShortTermMemoryPath("memory/2026-04-03.md")).toBe(true);
expect(isShortTermMemoryPath("2026-04-03.md")).toBe(true);
@@ -262,6 +273,20 @@ describe("short-term promotion", () => {
it("applies promotion candidates to MEMORY.md and marks them promoted", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"alpha",
"beta",
"gamma",
"delta",
"epsilon",
"zeta",
"eta",
"theta",
"iota",
"Gateway binds loopback and port 18789",
"Keep gateway on localhost only",
"Document healthcheck endpoint",
]);
await recordShortTermRecalls({
workspaceDir,
query: "gateway host",
@@ -294,7 +319,7 @@ describe("short-term promotion", () => {
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Promoted From Short-Term Memory");
expect(memoryText).toContain("memory/2026-04-01.md:10-12");
expect(memoryText).toContain("memory/2026-04-01.md:10-10");
const rankedAfter = await rankShortTermPromotionCandidates({
workspaceDir,
@@ -318,6 +343,20 @@ describe("short-term promotion", () => {
it("does not re-append candidates that were promoted in a prior run", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"alpha",
"beta",
"gamma",
"delta",
"epsilon",
"zeta",
"eta",
"theta",
"iota",
"Gateway binds loopback and port 18789",
"Keep gateway on localhost only",
"Document healthcheck endpoint",
]);
await recordShortTermRecalls({
workspaceDir,
query: "gateway host",
@@ -363,6 +402,229 @@ describe("short-term promotion", () => {
});
});
it("rehydrates moved snippets from the live daily note before promotion", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"intro",
"summary",
"Moved backups to S3 Glacier.",
"Keep cold storage retention at 365 days.",
]);
await recordShortTermRecalls({
workspaceDir,
query: "glacier",
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.94,
snippet: "Moved backups to S3 Glacier.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(applied.applied).toBe(1);
expect(applied.appliedCandidates[0]?.startLine).toBe(3);
expect(applied.appliedCandidates[0]?.endLine).toBe(3);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("memory/2026-04-01.md:3-3");
});
});
it("prefers the nearest matching snippet when the same text appears multiple times", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"header",
"Repeat backup note.",
"gap",
"gap",
"gap",
"gap",
"gap",
"gap",
"Repeat backup note.",
]);
await recordShortTermRecalls({
workspaceDir,
query: "backup repeat",
results: [
{
path: "memory/2026-04-01.md",
startLine: 8,
endLine: 9,
score: 0.9,
snippet: "Repeat backup note.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(applied.applied).toBe(1);
expect(applied.appliedCandidates[0]?.startLine).toBe(9);
expect(applied.appliedCandidates[0]?.endLine).toBe(10);
});
});
it("rehydrates legacy basename-only short-term paths from the memory directory", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", ["Legacy basename path note."]);
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: [
{
key: "memory:2026-04-01.md:1:1",
path: "2026-04-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "Legacy basename path note.",
recallCount: 2,
avgScore: 0.9,
maxScore: 0.95,
uniqueQueries: 2,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-02T00:00:00.000Z",
ageDays: 0,
score: 0.9,
recallDays: ["2026-04-01", "2026-04-02"],
conceptTags: ["legacy", "note"],
components: {
frequency: 0.3,
relevance: 0.9,
diversity: 0.4,
recency: 1,
consolidation: 0.5,
conceptual: 0.3,
},
},
],
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(applied.applied).toBe(1);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("source=2026-04-01.md:1-1");
});
});
it("skips promotion when the live daily note no longer contains the snippet", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", ["Different note content now."]);
await recordShortTermRecalls({
workspaceDir,
query: "glacier",
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.94,
snippet: "Moved backups to S3 Glacier.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(applied.applied).toBe(0);
await expect(fs.access(path.join(workspaceDir, "MEMORY.md"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
it("uses dreaming timezone for recall-day bucketing and promotion headers", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"Cross-midnight router maintenance window.",
]);
await recordShortTermRecalls({
workspaceDir,
query: "router window",
nowMs: Date.parse("2026-04-01T23:30:00.000Z"),
timezone: "America/Los_Angeles",
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Cross-midnight router maintenance window.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(ranked[0]?.recallDays).toEqual(["2026-04-01"]);
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse("2026-04-02T06:30:00.000Z"),
timezone: "America/Los_Angeles",
});
expect(applied.applied).toBe(1);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Promoted From Short-Term Memory (2026-04-01)");
});
});
it("audits and repairs invalid store metadata plus stale locks", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = resolveShortTermRecallStorePath(workspaceDir);

View File

@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status";
import {
deriveConceptTags,
MAX_CONCEPT_TAGS,
@@ -159,6 +160,7 @@ export type ApplyShortTermPromotionsOptions = {
minRecallCount?: number;
minUniqueQueries?: number;
nowMs?: number;
timezone?: string;
};
export type ApplyShortTermPromotionsResult = {
@@ -566,6 +568,7 @@ export async function recordShortTermRecalls(params: {
query: string;
results: MemorySearchResult[];
nowMs?: number;
timezone?: string;
}): Promise<void> {
const workspaceDir = params.workspaceDir?.trim();
if (!workspaceDir) {
@@ -600,7 +603,7 @@ export async function recordShortTermRecalls(params: {
const queryHashes = mergeQueryHashes(existing?.queryHashes ?? [], queryHash);
const recallDays = mergeRecentDistinct(
existing?.recallDays ?? [],
nowIso.slice(0, 10),
formatMemoryDreamingDay(nowMs, params.timezone),
MAX_RECALL_DAYS,
);
const conceptTags = deriveConceptTags({ path: normalizedPath, snippet });
@@ -746,8 +749,164 @@ export async function rankShortTermPromotionCandidates(
return sorted.slice(0, limit);
}
function buildPromotionSection(candidates: PromotionCandidate[], nowMs: number): string {
const sectionDate = new Date(nowMs).toISOString().slice(0, 10);
function resolveShortTermSourcePathCandidates(
workspaceDir: string,
candidatePath: string,
): string[] {
const normalizedPath = normalizeMemoryPath(candidatePath);
const basenames = [normalizedPath];
if (!normalizedPath.startsWith("memory/")) {
basenames.push(path.posix.join("memory", path.posix.basename(normalizedPath)));
}
const seen = new Set<string>();
const resolved: string[] = [];
for (const relativePath of basenames) {
const absolutePath = path.resolve(workspaceDir, relativePath);
if (seen.has(absolutePath)) {
continue;
}
seen.add(absolutePath);
resolved.push(absolutePath);
}
return resolved;
}
function normalizeRangeSnippet(lines: string[], startLine: number, endLine: number): string {
const startIndex = Math.max(0, startLine - 1);
const endIndex = Math.min(lines.length, endLine);
if (startIndex >= endIndex) {
return "";
}
return normalizeSnippet(lines.slice(startIndex, endIndex).join(" "));
}
function compareCandidateWindow(
targetSnippet: string,
windowSnippet: string,
): { matched: boolean; quality: number } {
if (!targetSnippet || !windowSnippet) {
return { matched: false, quality: 0 };
}
if (windowSnippet === targetSnippet) {
return { matched: true, quality: 3 };
}
if (windowSnippet.includes(targetSnippet)) {
return { matched: true, quality: 2 };
}
if (targetSnippet.includes(windowSnippet)) {
return { matched: true, quality: 1 };
}
return { matched: false, quality: 0 };
}
function relocateCandidateRange(
lines: string[],
candidate: PromotionCandidate,
): { startLine: number; endLine: number; snippet: string } | null {
const targetSnippet = normalizeSnippet(candidate.snippet);
const preferredSpan = Math.max(1, candidate.endLine - candidate.startLine + 1);
if (targetSnippet.length === 0) {
const fallbackSnippet = normalizeRangeSnippet(lines, candidate.startLine, candidate.endLine);
if (!fallbackSnippet) {
return null;
}
return {
startLine: candidate.startLine,
endLine: candidate.endLine,
snippet: fallbackSnippet,
};
}
const exactSnippet = normalizeRangeSnippet(lines, candidate.startLine, candidate.endLine);
if (exactSnippet === targetSnippet) {
return {
startLine: candidate.startLine,
endLine: candidate.endLine,
snippet: exactSnippet,
};
}
const maxSpan = Math.min(lines.length, Math.max(preferredSpan + 3, 8));
let bestMatch:
| { startLine: number; endLine: number; snippet: string; quality: number; distance: number }
| undefined;
for (let startIndex = 0; startIndex < lines.length; startIndex += 1) {
for (let span = 1; span <= maxSpan && startIndex + span <= lines.length; span += 1) {
const startLine = startIndex + 1;
const endLine = startIndex + span;
const snippet = normalizeRangeSnippet(lines, startLine, endLine);
const comparison = compareCandidateWindow(targetSnippet, snippet);
if (!comparison.matched) {
continue;
}
const distance = Math.abs(startLine - candidate.startLine);
if (
!bestMatch ||
comparison.quality > bestMatch.quality ||
(comparison.quality === bestMatch.quality && distance < bestMatch.distance) ||
(comparison.quality === bestMatch.quality &&
distance === bestMatch.distance &&
Math.abs(span - preferredSpan) <
Math.abs(bestMatch.endLine - bestMatch.startLine + 1 - preferredSpan))
) {
bestMatch = {
startLine,
endLine,
snippet,
quality: comparison.quality,
distance,
};
}
}
}
if (!bestMatch) {
return null;
}
return {
startLine: bestMatch.startLine,
endLine: bestMatch.endLine,
snippet: bestMatch.snippet,
};
}
async function rehydratePromotionCandidate(
workspaceDir: string,
candidate: PromotionCandidate,
): Promise<PromotionCandidate | null> {
const sourcePaths = resolveShortTermSourcePathCandidates(workspaceDir, candidate.path);
for (const sourcePath of sourcePaths) {
let rawSource: string;
try {
rawSource = await fs.readFile(sourcePath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
continue;
}
throw err;
}
const lines = rawSource.split(/\r?\n/);
const relocated = relocateCandidateRange(lines, candidate);
if (!relocated) {
continue;
}
return {
...candidate,
startLine: relocated.startLine,
endLine: relocated.endLine,
snippet: relocated.snippet,
};
}
return null;
}
function buildPromotionSection(
candidates: PromotionCandidate[],
nowMs: number,
timezone?: string,
): string {
const sectionDate = formatMemoryDreamingDay(nowMs, timezone);
const lines = ["", `## Promoted From Short-Term Memory (${sectionDate})`, ""];
for (const candidate of candidates) {
@@ -813,7 +972,15 @@ export async function applyShortTermPromotions(
})
.slice(0, limit);
if (selected.length === 0) {
const rehydratedSelected: PromotionCandidate[] = [];
for (const candidate of selected) {
const rehydrated = await rehydratePromotionCandidate(workspaceDir, candidate);
if (rehydrated) {
rehydratedSelected.push(rehydrated);
}
}
if (rehydratedSelected.length === 0) {
return {
memoryPath,
applied: 0,
@@ -829,18 +996,21 @@ export async function applyShortTermPromotions(
});
const header = existingMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
const section = buildPromotionSection(selected, nowMs);
const section = buildPromotionSection(rehydratedSelected, nowMs, options.timezone);
await fs.writeFile(
memoryPath,
`${header}${withTrailingNewline(existingMemory)}${section}`,
"utf-8",
);
for (const candidate of selected) {
for (const candidate of rehydratedSelected) {
const entry = store.entries[candidate.key];
if (!entry) {
continue;
}
entry.startLine = candidate.startLine;
entry.endLine = candidate.endLine;
entry.snippet = candidate.snippet;
entry.promotedAt = nowIso;
}
store.updatedAt = nowIso;
@@ -848,8 +1018,8 @@ export async function applyShortTermPromotions(
return {
memoryPath,
applied: selected.length,
appliedCandidates: selected,
applied: rehydratedSelected.length,
appliedCandidates: rehydratedSelected,
};
});
}

View File

@@ -6,6 +6,10 @@ import {
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import {
clampResultsByInjectedChars,
@@ -51,12 +55,14 @@ function queueShortTermRecallTracking(params: {
query: string;
rawResults: MemorySearchResult[];
surfacedResults: MemorySearchResult[];
timezone?: string;
}): void {
const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
void recordShortTermRecalls({
workspaceDir: params.workspaceDir,
query: params.query,
results: trackingResults,
timezone: params.timezone,
}).catch(() => {
// Recall tracking is best-effort and must never block memory recall.
});
@@ -102,11 +108,16 @@ export function createMemorySearchTool(options: {
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
const dreamingTimezone = resolveMemoryDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
}).timezone;
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
query,
rawResults,
surfacedResults: results,
timezone: dreamingTimezone,
});
const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
return jsonResult({