From f7670bde7efabde9d0b8ee0ab50b875e5d9d89d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 15:45:54 +0100 Subject: [PATCH] fix(memory-core): align dreaming promotion --- docs/cli/memory.md | 6 +- docs/concepts/memory-dreaming.md | 38 +- extensions/memory-core/src/cli.runtime.ts | 10 + extensions/memory-core/src/cli.test.ts | 28 +- extensions/memory-core/src/dreaming.test.ts | 105 ++++++ extensions/memory-core/src/dreaming.ts | 328 +++++++----------- .../src/short-term-promotion.test.ts | 264 +++++++++++++- .../memory-core/src/short-term-promotion.ts | 186 +++++++++- extensions/memory-core/src/tools.ts | 11 + src/gateway/server-methods/doctor.test.ts | 267 +++++++++++++- src/gateway/server-methods/doctor.ts | 198 +++++------ src/memory-host-sdk/dreaming.ts | 269 ++++++++++++++ src/plugin-sdk/memory-core-host-status.ts | 1 + src/plugin-sdk/memory-core.ts | 9 + 14 files changed, 1359 insertions(+), 361 deletions(-) create mode 100644 src/memory-host-sdk/dreaming.ts diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 29db96ce231..3dc6c8d01e1 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -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. diff --git a/docs/concepts/memory-dreaming.md b/docs/concepts/memory-dreaming.md index 53edf7a8481..8d70d639f2f 100644 --- a/docs/concepts/memory-dreaming.md +++ b/docs/concepts/memory-dreaming.md @@ -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 diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index 72228358523..c21ecdd5621 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -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> | 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)}`); diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 86b944c607a..81b30838bca 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -176,6 +176,16 @@ describe("memory cli", () => { } } + async function writeDailyMemoryNote( + workspaceDir: string, + date: string, + lines: string[], + ): Promise { + 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; @@ -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(); }); diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index f4ef5fb9bbc..3a99dc3496e 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -26,6 +26,16 @@ function createLogger() { }; } +async function writeDailyMemoryNote( + workspaceDir: string, + date: string, + lines: string[], +): Promise { + 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).", + ); + }); }); diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 69ea03cc5e3..cd35c0ed50c 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -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; @@ -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; 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(); + 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, }, }; diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index cd49fa6e278..3b3bafdd440 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -24,6 +24,17 @@ describe("short-term promotion", () => { } } + async function writeDailyMemoryNote( + workspaceDir: string, + date: string, + lines: string[], + ): Promise { + 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); diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index c70dbd8f6bc..ee8c7bb9cf1 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -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 { 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(); + 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 { + 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, }; }); } diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 27cfaeb7a42..a8b670f5c59 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -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({ diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 8a6c82407ac..36b0272c7b1 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -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("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 }); + } + }); }); diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 3b4626a9f48..eb8f05d388b 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -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; - -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, + 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 { 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(); + 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); + const dreamingConfig = resolveDreamingConfig(cfg); const workspaceDir = normalizeTrimmedString((status as Record).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, diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts new file mode 100644 index 00000000000..63677edc552 --- /dev/null +++ b/src/memory-host-sdk/dreaming.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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 | undefined, +): Record | 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; + 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(); + 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(); + 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()]; +} diff --git a/src/plugin-sdk/memory-core-host-status.ts b/src/plugin-sdk/memory-core-host-status.ts index fe626a778fa..a7196314403 100644 --- a/src/plugin-sdk/memory-core-host-status.ts +++ b/src/plugin-sdk/memory-core-host-status.ts @@ -1 +1,2 @@ export * from "../memory-host-sdk/status.js"; +export * from "../memory-host-sdk/dreaming.js"; diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index 038bbcf233f..541d4b6e83b 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -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,