Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a5e201dd9 | |||
| 7e9824e394 | |||
| 67afcd90d9 |
+4
-8
@@ -196,14 +196,9 @@ export const mem0ConfigSchema = {
|
||||
const mode: Mem0Mode =
|
||||
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
|
||||
|
||||
// Platform mode requires apiKey
|
||||
if (mode === "platform") {
|
||||
if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
|
||||
throw new Error(
|
||||
"apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Platform mode requires apiKey — but don't throw on missing config.
|
||||
// The plugin should register successfully and log a setup message.
|
||||
const needsSetup = mode === "platform" && (typeof cfg.apiKey !== "string" || !cfg.apiKey);
|
||||
|
||||
// Resolve env vars in oss config
|
||||
let ossConfig: Mem0Config["oss"];
|
||||
@@ -241,6 +236,7 @@ export const mem0ConfigSchema = {
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
needsSetup,
|
||||
oss: ossConfig,
|
||||
skills:
|
||||
cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills)
|
||||
|
||||
+38
-28
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+80
-22
@@ -48,7 +48,8 @@ import {
|
||||
import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
|
||||
import {
|
||||
incrementSessionCount,
|
||||
shouldDream,
|
||||
checkCheapGates,
|
||||
checkMemoryGate,
|
||||
acquireDreamLock,
|
||||
releaseDreamLock,
|
||||
recordDreamCompletion,
|
||||
@@ -93,6 +94,25 @@ const memoryPlugin = {
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
const cfg = mem0ConfigSchema.parse(api.pluginConfig);
|
||||
|
||||
// If API key is missing, register successfully but skip all memory operations.
|
||||
// This prevents install from failing and gives the user a clear setup message.
|
||||
if (cfg.needsSetup) {
|
||||
api.logger.warn(
|
||||
"openclaw-mem0: API key not configured. Memory features are disabled.\n" +
|
||||
" To set up, run:\n" +
|
||||
' openclaw config set plugins.entries.openclaw-mem0.config.apiKey "m0-your-key"\n' +
|
||||
" openclaw gateway restart\n" +
|
||||
" Get your key at: https://app.mem0.ai/dashboard/api-keys"
|
||||
);
|
||||
api.registerService({
|
||||
id: "openclaw-mem0",
|
||||
start: () => { api.logger.info("openclaw-mem0: waiting for API key configuration"); },
|
||||
stop: () => {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Track current session ID for tool-level session scoping.
|
||||
@@ -1306,23 +1326,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<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n</auto-dream>";
|
||||
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<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n</auto-dream>";
|
||||
// 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 +1362,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);
|
||||
|
||||
@@ -28,6 +28,8 @@ export type Mem0Config = {
|
||||
autoRecall: boolean;
|
||||
searchThreshold: number;
|
||||
topK: number;
|
||||
// Setup state
|
||||
needsSetup?: boolean;
|
||||
// Agentic harness skills
|
||||
skills?: SkillsConfig;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user