diff --git a/openclaw/dream-gate.ts b/openclaw/dream-gate.ts index 3b8cc46f2..46435a2b5 100644 --- a/openclaw/dream-gate.ts +++ b/openclaw/dream-gate.ts @@ -92,38 +92,43 @@ export function incrementSessionCount(stateDir: string, sessionId: string): void // ============================================================================ /** - * Check all three gates. Returns true only if ALL pass. - * Gates are checked cheapest-first (time, session, memory). + * Check cheap gates (time + sessions). These are local file reads only. + * Call this BEFORE any API calls. If this fails, skip the expensive + * memory count check entirely. */ -export async function shouldDream( +export function checkCheapGates( stateDir: string, - config: { minHours?: number; minSessions?: number; minMemories?: number }, - memoryCount: number, -): Promise<{ pass: boolean; reason?: string }> { - const cfg: DreamGateConfig = { - minHours: config.minHours ?? DEFAULTS.minHours, - minSessions: config.minSessions ?? DEFAULTS.minSessions, - minMemories: config.minMemories ?? DEFAULTS.minMemories, - }; - + config: { minHours?: number; minSessions?: number }, +): { proceed: boolean; reason?: string } { + const minHours = config.minHours ?? DEFAULTS.minHours; + const minSessions = config.minSessions ?? DEFAULTS.minSessions; const state = readState(stateDir); - // Gate 1: Time + // Gate 1: Time (one local file read) const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000; - if (hoursSince < cfg.minHours) { - return { pass: false, reason: `time: ${hoursSince.toFixed(1)}h < ${cfg.minHours}h` }; + if (hoursSince < minHours) { + return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` }; } - // Gate 2: Sessions - if (state.sessionsSince < cfg.minSessions) { - return { pass: false, reason: `sessions: ${state.sessionsSince} < ${cfg.minSessions}` }; + // Gate 2: Sessions (same file, already read) + if (state.sessionsSince < minSessions) { + return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` }; } - // Gate 3: Memory count - if (memoryCount < cfg.minMemories) { - return { pass: false, reason: `memories: ${memoryCount} < ${cfg.minMemories}` }; - } + return { proceed: true }; +} +/** + * Check expensive memory count gate. Only call AFTER checkCheapGates passes. + */ +export function checkMemoryGate( + memoryCount: number, + config: { minMemories?: number }, +): { pass: boolean; reason?: string } { + const minMemories = config.minMemories ?? DEFAULTS.minMemories; + if (memoryCount < minMemories) { + return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` }; + } return { pass: true }; } @@ -145,18 +150,23 @@ export function acquireDreamLock(stateDir: string): boolean { const lock = JSON.parse(raw) as DreamLock; const age = Date.now() - lock.startedAt; if (age < LOCK_STALE_MS) { - // Lock is held and not stale - return false; + return false; // Held and not stale } - // Stale lock, reclaim + // Stale lock — remove it before attempting exclusive create + try { fs.unlinkSync(lp); } catch { /* race ok */ } } catch { // No lock file, proceed } - // Write lock + // Atomic create with exclusive flag (wx). If two processes race, + // only one succeeds. The other gets EEXIST. const lock: DreamLock = { pid: process.pid, startedAt: Date.now() }; - fs.writeFileSync(lp, JSON.stringify(lock)); - return true; + try { + fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" }); + return true; + } catch { + return false; // Lost race + } } /** diff --git a/openclaw/index.ts b/openclaw/index.ts index 301074c17..2900b92b0 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -48,7 +48,8 @@ import { import { recall as skillRecall, sanitizeQuery } from "./recall.ts"; import { incrementSessionCount, - shouldDream, + checkCheapGates, + checkMemoryGate, acquireDreamLock, releaseDreamLock, recordDreamCompletion, @@ -1306,23 +1307,29 @@ function registerHooks( api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search"); } - // Auto-dream: check gates and inject consolidation protocol if due + // Auto-dream: check CHEAP gates first (local file reads only). + // Only hit the API for memory count if time + session gates pass. const stateDir = session.getStateDir(); const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false; let dreamContext = ""; if (dreamEnabled && stateDir && !isSubagent) { try { - const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" }); - const memCount = Array.isArray(memories) ? memories.length : 0; - const gateResult = await shouldDream(stateDir, cfg.skills?.dream ?? {}, memCount); - if (gateResult.pass && acquireDreamLock(stateDir)) { - const dreamPrompt = loadDreamPrompt(cfg.skills ?? {}); - if (dreamPrompt) { - dreamContext = "\n\n\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n"; - dreamJustRan = true; - api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`); - } else { - releaseDreamLock(stateDir); + const cheapResult = checkCheapGates(stateDir, cfg.skills?.dream ?? {}); + if (cheapResult.proceed) { + // Cheap gates passed. Now do the expensive memory count check. + const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" }); + const memCount = Array.isArray(memories) ? memories.length : 0; + const memResult = checkMemoryGate(memCount, cfg.skills?.dream ?? {}); + if (memResult.pass && acquireDreamLock(stateDir)) { + const dreamPrompt = loadDreamPrompt(cfg.skills ?? {}); + if (dreamPrompt) { + dreamContext = "\n\n\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n"; + // Track which session triggered dream (session-keyed, not global) + dreamSessionId = sessionId; + api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`); + } else { + releaseDreamLock(stateDir); + } } } } catch (err) { @@ -1336,25 +1343,57 @@ function registerHooks( }; }); - // Skills mode: NO auto-capture. Track sessions for dream gating. - let dreamJustRan = false; + // Session-keyed dream tracking. Only the session that triggered dream + // can complete it. Prevents cross-session false completion. + let dreamSessionId: string | undefined; api.on("agent_end", async (event: any, ctx: any) => { - if (!event.success) return; const sessionId = ctx?.sessionKey ?? undefined; const trigger = ctx?.trigger ?? undefined; if (sessionId) session.setCurrentSessionId(sessionId); - // If dream just ran, release lock and record completion + // If dream was triggered for THIS session, handle cleanup regardless + // of success/failure. A failed turn must still release the lock. const stateDir = session.getStateDir(); - if (dreamJustRan && stateDir) { - releaseDreamLock(stateDir); - recordDreamCompletion(stateDir); - api.logger.info("openclaw-mem0: auto-dream completed, lock released"); - dreamJustRan = false; + if (dreamSessionId && dreamSessionId === sessionId && stateDir) { + dreamSessionId = undefined; + + if (!event.success) { + // Turn failed/aborted after lock acquired. Release lock, do not + // record completion. Gates will re-trigger next eligible turn. + releaseDreamLock(stateDir); + api.logger.warn("openclaw-mem0: auto-dream turn failed, lock released, will retry"); + return; + } + + // Verify the model actually performed WRITE operations (not just reads). + // Only count memory_store, memory_update, memory_forget, memory_delete_all. + // Exclude memory_list and memory_search (read-only, orient-only pass). + // Scan only the LAST assistant message (this turn), not the full session + // snapshot, to avoid matching earlier tool calls from prior turns. + const WRITE_TOOLS = new Set(["memory_store", "memory_update", "memory_forget", "memory_delete_all"]); + const messages = event.messages ?? []; + // Find the last assistant message (this turn's output) + const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant"); + const writeToolUsed = lastAssistant && Array.isArray(lastAssistant.content) + ? lastAssistant.content.some((block: any) => + block.type === "tool_use" && WRITE_TOOLS.has(block.name) + ) + : false; + + if (writeToolUsed) { + releaseDreamLock(stateDir); + recordDreamCompletion(stateDir); + api.logger.info("openclaw-mem0: auto-dream completed (verified write tool usage), lock released"); + } else { + releaseDreamLock(stateDir); + api.logger.warn("openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry."); + } return; } + if (!event.success) return; + // Track session for dream gating (interactive turns only) if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) { incrementSessionCount(stateDir, sessionId);