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
No known key found for this signature in database
14 changed files with 1359 additions and 361 deletions

View File

@ -99,8 +99,10 @@ Dreaming is the overnight reflection pass for memory. It is called "dreaming" be
- You can toggle modes from chat with `/dreaming off|core|rem|deep`. Run `/dreaming` (or `/dreaming options`) to see what each mode does.
- When enabled, `memory-core` automatically creates and maintains a managed cron job.
- Set `dreaming.limit` to `0` if you want dreaming enabled but automatic promotion effectively paused.
- Ranking uses weighted signals: recall frequency, retrieval relevance, query diversity, and temporal recency (recent recalls decay over time).
- Ranking uses weighted signals: recall frequency, retrieval relevance, query diversity, temporal recency, cross-day consolidation, and derived concept richness.
- 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.
Default mode presets:
@ -132,5 +134,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.frequency` 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`, and `minUniqueQueries`.
- Set `plugins.entries.memory-core.config.dreaming.verboseLogging` to `true` to emit per-run candidate and apply details into the normal gateway logs while tuning the feature.

View File

@ -28,34 +28,43 @@ one-off details.
## Promotion signals
Dreaming combines four signals:
Dreaming combines six signals:
- **Frequency**: how often the same candidate was recalled.
- **Relevance**: how strong recall scores were when it was retrieved.
- **Query diversity**: how many distinct query intents surfaced it.
- **Recency**: temporal weighting over recent recalls.
- **Consolidation**: whether recalls repeated across distinct days instead of one burst.
- **Conceptual richness**: derived concept tags from the note path and snippet text.
Promotion requires all configured threshold gates to pass, not just one signal.
### Signal weights
| Signal | Weight | Description |
| --------- | ------ | ------------------------------------------------ |
| Frequency | 0.35 | How often the same entry was recalled |
| Relevance | 0.35 | Average recall scores when retrieved |
| Diversity | 0.15 | Count of distinct query intents that surfaced it |
| Recency | 0.15 | Temporal decay (14-day half-life) |
| 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 |
## How it works
1. **Recall tracking** -- Every `memory_search` hit is recorded to
`memory/.dreams/short-term-recall.json` with recall count, scores, and query
hash.
`memory/.dreams/short-term-recall.json` with recall count, scores, query
hash, recall days, and concept tags.
2. **Scheduled scoring** -- On the configured cadence, candidates are ranked
using weighted signals. All threshold gates must pass simultaneously.
3. **Promotion** -- Qualifying entries are appended to `MEMORY.md` with a
promoted timestamp.
4. **Cleanup** -- Already-promoted entries are filtered from future cycles. A
3. **Workspace fan-out** -- Each dreaming cycle runs once per configured memory
workspace, so one agent's sessions consolidate into that agent's memory
workspace.
4. **Promotion** -- Before appending anything, dreaming re-reads the current
daily note and skips candidates whose source snippet no longer exists.
Qualifying live entries are appended to `MEMORY.md` with a promoted
timestamp.
5. **Cleanup** -- Already-promoted entries are filtered from future cycles. A
file lock prevents concurrent runs.
## Modes
@ -76,7 +85,7 @@ automatically. You do not need to manually create a cron job for this feature.
You can still tune behavior with explicit overrides such as:
- `dreaming.frequency` (cron expression)
- `dreaming.cron` (cron expression)
- `dreaming.timezone`
- `dreaming.limit`
- `dreaming.minScore`
@ -142,7 +151,8 @@ See [memory CLI](/cli/memory) for the full flag reference.
When dreaming is enabled, the Gateway sidebar shows a **Dreams** tab with
memory stats (short-term count, long-term count, promoted count) and the next
scheduled cycle time.
scheduled cycle time. Daily counters honor `dreaming.timezone` when set and
otherwise fall back to the configured user timezone.
## Further reading

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({

View File

@ -6,6 +6,12 @@ import type { OpenClawConfig } from "../../config/config.js";
const loadConfig = vi.hoisted(() => vi.fn(() => ({}) as OpenClawConfig));
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main"));
const resolveAgentWorkspaceDir = vi.hoisted(() =>
vi.fn((_cfg: OpenClawConfig, _agentId: string) => "/tmp/openclaw"),
);
const resolveMemorySearchConfig = vi.hoisted(() =>
vi.fn((_cfg: OpenClawConfig, _agentId: string) => ({ enabled: true })),
);
const getMemorySearchManager = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", () => ({
@ -14,6 +20,11 @@ vi.mock("../../config/config.js", () => ({
vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
resolveAgentWorkspaceDir,
}));
vi.mock("../../agents/memory-search.js", () => ({
resolveMemorySearchConfig,
}));
vi.mock("../../plugins/memory-runtime.js", () => ({
@ -63,6 +74,8 @@ describe("doctor.memory.status", () => {
beforeEach(() => {
loadConfig.mockClear();
resolveDefaultAgentId.mockClear();
resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw");
resolveMemorySearchConfig.mockReset().mockReturnValue({ enabled: true });
getMemorySearchManager.mockReset();
});
@ -134,18 +147,34 @@ describe("doctor.memory.status", () => {
});
it("includes dreaming counts and managed cron status when workspace data is available", async () => {
const now = Date.now();
const todayIso = new Date(now).toISOString();
const earlierIso = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
const now = Date.parse("2026-04-05T00:30:00.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
const recentIso = "2026-04-04T23:45:00.000Z";
const olderIso = "2026-04-02T10:00:00.000Z";
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
const mainWorkspaceDir = path.join(workspaceRoot, "main");
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
const mainStorePath = path.join(
mainWorkspaceDir,
"memory",
".dreams",
"short-term-recall.json",
);
const alphaStorePath = path.join(
alphaWorkspaceDir,
"memory",
".dreams",
"short-term-recall.json",
);
await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
await fs.writeFile(
storePath,
mainStorePath,
`${JSON.stringify(
{
version: 1,
updatedAt: todayIso,
updatedAt: recentIso,
entries: {
"memory:memory/2026-04-03.md:1:2": {
path: "memory/2026-04-03.md",
@ -155,16 +184,31 @@ describe("doctor.memory.status", () => {
"memory:memory/2026-04-02.md:1:2": {
path: "memory/2026-04-02.md",
source: "memory",
promotedAt: todayIso,
promotedAt: recentIso,
},
},
},
null,
2,
)}\n`,
"utf-8",
);
await fs.writeFile(
alphaStorePath,
`${JSON.stringify(
{
version: 1,
updatedAt: recentIso,
entries: {
"memory:memory/2026-04-01.md:1:2": {
path: "memory/2026-04-01.md",
source: "memory",
promotedAt: earlierIso,
promotedAt: olderIso,
},
"memory:MEMORY.md:1:2": {
path: "MEMORY.md",
"memory:memory/2026-04-04.md:1:2": {
path: "memory/2026-04-04.md",
source: "memory",
promotedAt: recentIso,
},
},
},
@ -175,24 +219,42 @@ describe("doctor.memory.status", () => {
);
loadConfig.mockReturnValue({
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
memorySearch: {
enabled: true,
},
},
list: [
{ id: "main", workspace: mainWorkspaceDir },
{ id: "alpha", workspace: alphaWorkspaceDir },
],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
mode: "rem",
frequency: "0 */4 * * *",
cron: "0 */4 * * *",
},
},
},
},
},
} as OpenClawConfig);
resolveAgentWorkspaceDir.mockImplementation((cfg: OpenClawConfig, agentId: string) => {
if (agentId === "alpha") {
return alphaWorkspaceDir;
}
return mainWorkspaceDir;
});
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ provider: "gemini", workspaceDir }),
status: () => ({ provider: "gemini", workspaceDir: mainWorkspaceDir }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
close,
},
@ -224,9 +286,10 @@ describe("doctor.memory.status", () => {
mode: "rem",
enabled: true,
frequency: "0 */4 * * *",
timezone: "America/Los_Angeles",
shortTermCount: 1,
promotedTotal: 2,
promotedToday: 1,
promotedTotal: 3,
promotedToday: 2,
managedCronPresent: true,
nextRunAtMs: now + 60_000,
}),
@ -234,8 +297,180 @@ describe("doctor.memory.status", () => {
undefined,
);
expect(close).toHaveBeenCalled();
} finally {
vi.useRealTimers();
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});
it("falls back to the manager workspace when no configured dreaming workspaces resolve", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-fallback-"));
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
"memory:memory/2026-04-03.md:1:2": {
path: "memory/2026-04-03.md",
source: "memory",
promotedAt: "2026-04-04T00:00:00.000Z",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
resolveMemorySearchConfig.mockReturnValue(null);
loadConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
mode: "core",
},
},
},
},
},
} as OpenClawConfig);
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ provider: "gemini", workspaceDir }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
close,
},
});
const respond = vi.fn();
try {
await invokeDoctorMemoryStatus(respond);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
dreaming: expect.objectContaining({
shortTermCount: 0,
promotedTotal: 1,
managedCronPresent: false,
storePath,
}),
}),
undefined,
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("merges workspace store errors when multiple workspace stores are unreadable", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-error-"));
const mainWorkspaceDir = path.join(workspaceRoot, "main");
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
const alphaStorePath = path.join(
alphaWorkspaceDir,
"memory",
".dreams",
"short-term-recall.json",
);
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
await fs.writeFile(
alphaStorePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {},
},
null,
2,
)}\n`,
"utf-8",
);
await fs.mkdir(path.join(mainWorkspaceDir, "memory", ".dreams"), { recursive: true });
loadConfig.mockReturnValue({
agents: {
defaults: {
memorySearch: {
enabled: true,
},
},
list: [
{ id: "main", workspace: mainWorkspaceDir },
{ id: "alpha", workspace: alphaWorkspaceDir },
],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
mode: "core",
},
},
},
},
},
} as OpenClawConfig);
resolveAgentWorkspaceDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) =>
agentId === "alpha" ? alphaWorkspaceDir : mainWorkspaceDir,
);
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (target, options) => {
const targetPath =
typeof target === "string"
? target
: Buffer.isBuffer(target)
? target.toString("utf-8")
: target instanceof URL
? target.pathname
: "";
if (
targetPath === path.join(mainWorkspaceDir, "memory", ".dreams", "short-term-recall.json") ||
targetPath === alphaStorePath
) {
const error = Object.assign(new Error("denied"), { code: "EACCES" });
throw error;
}
return await vi
.importActual<typeof import("node:fs/promises")>("node:fs/promises")
.then((actual) => actual.readFile(target, options as never));
});
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ provider: "gemini", workspaceDir: mainWorkspaceDir }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
close,
},
});
const respond = vi.fn();
try {
await invokeDoctorMemoryStatus(respond);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
dreaming: expect.objectContaining({
shortTermCount: 0,
promotedTotal: 0,
storeError: "2 dreaming stores had read errors.",
}),
}),
undefined,
);
} finally {
readFileSpy.mockRestore();
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});
});

View File

@ -2,55 +2,25 @@ import fs from "node:fs/promises";
import path from "node:path";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
isSameMemoryDreamingDay,
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryDreamingWorkspaces,
type MemoryDreamingMode,
} from "../../memory-host-sdk/dreaming.js";
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { formatError } from "../server-utils.js";
import type { GatewayRequestHandlers } from "./types.js";
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
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__";
type DreamingMode = "off" | "core" | "rem" | "deep";
type DreamingPreset = Exclude<DreamingMode, "off">;
const DREAMING_PRESET_DEFAULTS: Record<
DreamingPreset,
{
frequency: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
}
> = {
core: {
frequency: "0 3 * * *",
limit: 10,
minScore: 0.75,
minRecallCount: 3,
minUniqueQueries: 2,
},
deep: {
frequency: "0 */12 * * *",
limit: 10,
minScore: 0.8,
minRecallCount: 3,
minUniqueQueries: 3,
},
rem: {
frequency: "0 */6 * * *",
limit: 10,
minScore: 0.85,
minRecallCount: 4,
minUniqueQueries: 3,
},
};
type DoctorMemoryDreamingPayload = {
mode: DreamingMode;
mode: MemoryDreamingMode;
enabled: boolean;
frequency: string;
timezone?: string;
@ -93,39 +63,8 @@ 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 === "rem" ||
normalized === "deep"
) {
return normalized;
}
return "off";
}
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const floored = Math.floor(value);
return floored < 0 ? fallback : floored;
}
function normalizeScore(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
if (value < 0 || value > 1) {
return fallback;
}
return value;
}
function resolveDreamingConfig(
cfg: Record<string, unknown>,
cfg: OpenClawConfig,
): Omit<
DoctorMemoryDreamingPayload,
| "shortTermCount"
@ -137,27 +76,19 @@ function resolveDreamingConfig(
| "managedCronPresent"
| "storeError"
> {
const plugins = asRecord(cfg.plugins);
const entries = asRecord(plugins?.entries);
const memoryCore = asRecord(entries?.["memory-core"]);
const pluginConfig = asRecord(memoryCore?.config);
const dreaming = asRecord(pluginConfig?.dreaming);
const mode = normalizeDreamingMode(dreaming?.mode);
const preset: DreamingPreset = mode === "off" ? "core" : mode;
const defaults = DREAMING_PRESET_DEFAULTS[preset];
const resolved = resolveMemoryDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
});
return {
mode,
enabled: mode !== "off",
frequency: normalizeTrimmedString(dreaming?.frequency) ?? defaults.frequency,
timezone: normalizeTrimmedString(dreaming?.timezone),
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
minUniqueQueries: normalizeNonNegativeInt(
dreaming?.minUniqueQueries,
defaults.minUniqueQueries,
),
mode: resolved.mode,
enabled: resolved.enabled,
frequency: resolved.cron,
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
limit: resolved.limit,
minScore: resolved.minScore,
minRecallCount: resolved.minRecallCount,
minUniqueQueries: resolved.minUniqueQueries,
};
}
@ -167,20 +98,10 @@ function normalizeMemoryPath(rawPath: string): string {
function isShortTermMemoryPath(filePath: string): boolean {
const normalized = normalizeMemoryPath(filePath);
if (SHORT_TERM_PATH_RE.test(normalized)) {
if (/(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized)) {
return true;
}
return SHORT_TERM_BASENAME_RE.test(normalized);
}
function isSameLocalDay(firstEpochMs: number, secondEpochMs: number): boolean {
const first = new Date(firstEpochMs);
const second = new Date(secondEpochMs);
return (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
);
return /^(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized);
}
type DreamingStoreStats = Pick<
@ -196,6 +117,7 @@ type DreamingStoreStats = Pick<
async function loadDreamingStoreStats(
workspaceDir: string,
nowMs: number,
timezone?: string,
): Promise<DreamingStoreStats> {
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
try {
@ -226,7 +148,7 @@ async function loadDreamingStoreStats(
}
promotedTotal += 1;
const promotedAtMs = Date.parse(promotedAt);
if (Number.isFinite(promotedAtMs) && isSameLocalDay(promotedAtMs, nowMs)) {
if (Number.isFinite(promotedAtMs) && isSameMemoryDreamingDay(promotedAtMs, nowMs, timezone)) {
promotedToday += 1;
}
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
@ -262,6 +184,46 @@ async function loadDreamingStoreStats(
}
}
function mergeDreamingStoreStats(stats: DreamingStoreStats[]): DreamingStoreStats {
let shortTermCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
let lastPromotedAt: string | undefined;
const storePaths = new Set<string>();
const storeErrors: string[] = [];
for (const stat of stats) {
shortTermCount += stat.shortTermCount;
promotedTotal += stat.promotedTotal;
promotedToday += stat.promotedToday;
if (stat.storePath) {
storePaths.add(stat.storePath);
}
if (stat.storeError) {
storeErrors.push(stat.storeError);
}
const promotedAtMs = stat.lastPromotedAt ? Date.parse(stat.lastPromotedAt) : Number.NaN;
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
latestPromotedAtMs = promotedAtMs;
lastPromotedAt = stat.lastPromotedAt;
}
}
return {
shortTermCount,
promotedTotal,
promotedToday,
...(storePaths.size === 1 ? { storePath: [...storePaths][0] } : {}),
...(lastPromotedAt ? { lastPromotedAt } : {}),
...(storeErrors.length === 1
? { storeError: storeErrors[0] }
: storeErrors.length > 1
? { storeError: `${storeErrors.length} dreaming stores had read errors.` }
: {}),
};
}
type ManagedDreamingCronStatus = {
managedCronPresent: boolean;
nextRunAtMs?: number;
@ -351,15 +313,27 @@ export const doctorHandlers: GatewayRequestHandlers = {
embedding = { ok: false, error: "memory embeddings unavailable" };
}
const nowMs = Date.now();
const dreamingConfig = resolveDreamingConfig(cfg as Record<string, unknown>);
const dreamingConfig = resolveDreamingConfig(cfg);
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
const storeStats = workspaceDir
? await loadDreamingStoreStats(workspaceDir, nowMs)
: {
shortTermCount: 0,
promotedTotal: 0,
promotedToday: 0,
};
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg).map(
(entry) => entry.workspaceDir,
);
const allWorkspaces =
configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : [];
const storeStats =
allWorkspaces.length > 0
? mergeDreamingStoreStats(
await Promise.all(
allWorkspaces.map((entry) =>
loadDreamingStoreStats(entry, nowMs, dreamingConfig.timezone),
),
),
)
: {
shortTermCount: 0,
promotedTotal: 0,
promotedToday: 0,
};
const cronStatus = await resolveManagedDreamingCronStatus(context);
const payload: DoctorMemoryStatusPayload = {
agentId,

View File

@ -0,0 +1,269 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { OpenClawConfig } from "../config/config.js";
export const DEFAULT_MEMORY_DREAMING_CRON_EXPR = "0 3 * * *";
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_MODE = "off";
export const DEFAULT_MEMORY_DREAMING_PRESET = "core";
export type MemoryDreamingPreset = "core" | "deep" | "rem";
export type MemoryDreamingMode = MemoryDreamingPreset | "off";
export type MemoryDreamingConfig = {
mode: MemoryDreamingMode;
enabled: boolean;
cron: string;
timezone?: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
verboseLogging: boolean;
};
export type MemoryDreamingWorkspace = {
workspaceDir: string;
agentIds: string[];
};
export const MEMORY_DREAMING_PRESET_DEFAULTS: Record<
MemoryDreamingPreset,
{
cron: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
}
> = {
core: {
cron: DEFAULT_MEMORY_DREAMING_CRON_EXPR,
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
minScore: DEFAULT_MEMORY_DREAMING_MIN_SCORE,
minRecallCount: DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
},
deep: {
cron: "0 */12 * * *",
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
minScore: 0.8,
minRecallCount: 3,
minUniqueQueries: 3,
},
rem: {
cron: "0 */6 * * *",
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
minScore: 0.85,
minRecallCount: 4,
minUniqueQueries: 3,
},
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
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) || 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 normalizePathForComparison(input: string): string {
const normalized = path.resolve(input);
if (process.platform === "win32") {
return normalized.toLowerCase();
}
return normalized;
}
function formatLocalIsoDay(epochMs: number): string {
const date = new Date(epochMs);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function normalizeMemoryDreamingMode(value: unknown): MemoryDreamingMode {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (
normalized === "off" ||
normalized === "core" ||
normalized === "deep" ||
normalized === "rem"
) {
return normalized;
}
return DEFAULT_MEMORY_DREAMING_MODE;
}
export function resolveMemoryCorePluginConfig(
cfg: OpenClawConfig | Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const root = asRecord(cfg);
const plugins = asRecord(root?.plugins);
const entries = asRecord(plugins?.entries);
const memoryCore = asRecord(entries?.["memory-core"]);
return asRecord(memoryCore?.config) ?? undefined;
}
export function resolveMemoryDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): MemoryDreamingConfig {
const dreaming = asRecord(params.pluginConfig?.dreaming);
const mode = normalizeMemoryDreamingMode(dreaming?.mode);
const enabled = mode !== "off";
const preset: MemoryDreamingPreset = mode === "off" ? DEFAULT_MEMORY_DREAMING_PRESET : mode;
const defaults = MEMORY_DREAMING_PRESET_DEFAULTS[preset];
const timezone =
normalizeTrimmedString(dreaming?.timezone) ??
normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone);
return {
mode,
enabled,
cron:
normalizeTrimmedString(dreaming?.cron) ??
normalizeTrimmedString(dreaming?.frequency) ??
defaults.cron,
...(timezone ? { timezone } : {}),
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
minUniqueQueries: normalizeNonNegativeInt(
dreaming?.minUniqueQueries,
defaults.minUniqueQueries,
),
verboseLogging: normalizeBoolean(dreaming?.verboseLogging, false),
};
}
export function formatMemoryDreamingDay(epochMs: number, timezone?: string): string {
if (!timezone) {
return formatLocalIsoDay(epochMs);
}
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(epochMs));
const values = new Map(parts.map((part) => [part.type, part.value]));
const year = values.get("year");
const month = values.get("month");
const day = values.get("day");
if (year && month && day) {
return `${year}-${month}-${day}`;
}
} catch {
// Fall back to host-local day for invalid or unsupported timezones.
}
return formatLocalIsoDay(epochMs);
}
export function isSameMemoryDreamingDay(
firstEpochMs: number,
secondEpochMs: number,
timezone?: string,
): boolean {
return (
formatMemoryDreamingDay(firstEpochMs, timezone) ===
formatMemoryDreamingDay(secondEpochMs, timezone)
);
}
export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDreamingWorkspace[] {
const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const agentIds: string[] = [];
const seenAgents = new Set<string>();
for (const entry of configured) {
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
continue;
}
const id = entry.id.trim().toLowerCase();
if (!id || seenAgents.has(id)) {
continue;
}
seenAgents.add(id);
agentIds.push(id);
}
if (agentIds.length === 0) {
agentIds.push(resolveDefaultAgentId(cfg));
}
const byWorkspace = new Map<string, MemoryDreamingWorkspace>();
for (const agentId of agentIds) {
if (!resolveMemorySearchConfig(cfg, agentId)) {
continue;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId)?.trim();
if (!workspaceDir) {
continue;
}
const key = normalizePathForComparison(workspaceDir);
const existing = byWorkspace.get(key);
if (existing) {
existing.agentIds.push(agentId);
continue;
}
byWorkspace.set(key, {
workspaceDir,
agentIds: [agentId],
});
}
return [...byWorkspace.values()];
}

View File

@ -1 +1,2 @@
export * from "../memory-host-sdk/status.js";
export * from "../memory-host-sdk/dreaming.js";

View File

@ -46,6 +46,15 @@ export {
withProgress,
withProgressTotals,
} from "./memory-core-host-runtime-cli.js";
export {
formatMemoryDreamingDay,
isSameMemoryDreamingDay,
MEMORY_DREAMING_PRESET_DEFAULTS,
normalizeMemoryDreamingMode,
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryDreamingWorkspaces,
} from "./memory-core-host-status.js";
export {
listMemoryFiles,
normalizeExtraMemoryPaths,