Files
wehub-resource-sync 555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

1060 lines
37 KiB
TypeScript

/**
* OpenClaw Memory (Mem0) Plugin
*
* Long-term memory via Mem0 — supports both the Mem0 platform
* and the open-source self-hosted SDK. Uses the official `mem0ai` package.
*
* Features:
* - 6 core tools: memory_search, memory_add, memory_get, memory_list,
* memory_update, memory_delete
* - Short-term (session-scoped) and long-term (user-scoped) memory
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
* - Auto-capture: stores key facts scoped to the current session after each agent turn
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
* - CLI: openclaw mem0 search, openclaw mem0 status
* - Dual mode: platform or open-source (self-hosted)
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type {
Mem0Config,
Mem0Provider,
AddOptions,
SearchOptions,
} from "./types.ts";
import {
createProvider,
customCategoryMapToList,
providerToBackend,
} from "./providers.ts";
import { mem0ConfigSchema } from "./config.ts";
import type { FileConfig } from "./config.ts";
import { createPublicArtifactsProvider } from "./public-artifacts.ts";
import { filterMessagesForExtraction } from "./filtering.ts";
import {
effectiveUserId,
agentUserId,
resolveUserId,
isNonInteractiveTrigger,
isSubagentSession,
} from "./isolation.ts";
import {
loadCompactTriagePrompt,
loadDreamPrompt,
isSkillsMode,
} from "./skill-loader.ts";
import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
import {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream-gate.ts";
import { PlatformBackend } from "./backend/platform.ts";
import type { Backend } from "./backend/base.ts";
import { registerCliCommands } from "./cli/commands.ts";
import { readPluginAuth } from "./cli/config-file.ts";
import { registerAllTools } from "./tools/index.ts";
import type { ToolDeps } from "./tools/index.ts";
import { captureEvent } from "./telemetry.ts";
import { bootstrapTelemetryFlag } from "./fs-safe.ts";
// ============================================================================
// Re-exports (for tests and external consumers)
// ============================================================================
export {
extractAgentId,
effectiveUserId,
agentUserId,
resolveUserId,
isNonInteractiveTrigger,
isSubagentSession,
} from "./isolation.ts";
export {
isNoiseMessage,
isGenericAssistantMessage,
isSessionSpecificContent,
stripNoiseFromContent,
filterMessagesForExtraction,
} from "./filtering.ts";
export { mem0ConfigSchema } from "./config.ts";
export type { FileConfig } from "./config.ts";
export { createProvider } from "./providers.ts";
// ============================================================================
// Helpers
// ============================================================================
// ============================================================================
// Plugin Definition
// ============================================================================
const memoryPlugin = definePluginEntry({
id: "openclaw-mem0",
name: "Memory (Mem0)",
description: "Mem0 memory backend — Mem0 platform or self-hosted open-source",
register(api: OpenClawPluginApi) {
bootstrapTelemetryFlag();
// Read auth from openclaw.json plugin config (picks up post-startup login).
// This is the single source of truth — set via `openclaw mem0 login`.
const pluginAuth = readPluginAuth();
const fileConfig: FileConfig = {
apiKey: pluginAuth.apiKey,
baseUrl: pluginAuth.baseUrl,
};
const cfg = mem0ConfigSchema.parse(api.pluginConfig, fileConfig);
const isMetadataRegistration = api.registrationMode === "cli-metadata";
// Telemetry context bound to this plugin instance's config
const telemetryCtx = {
apiKey: cfg.apiKey,
mode: cfg.mode,
skillsActive: false,
};
const _captureEvent = (event: string, props?: Record<string, unknown>) => {
try {
captureEvent(event, props, telemetryCtx);
} catch {
/* silently swallow */
}
};
if (isMetadataRegistration) {
registerCliCommands(
api,
null as any,
null as any,
cfg,
() => cfg.userId,
(id: string) => `${cfg.userId}:agent:${id}`,
() => ({ user_id: cfg.userId, top_k: cfg.topK }),
() => undefined,
(cmd: string) => _captureEvent(`openclaw.cli.${cmd}`, { command: cmd }),
);
return;
}
if (cfg.needsSetup) {
api.logger.warn(
"openclaw-mem0: API key not configured. Memory features are disabled.\n" +
" To set up, run:\n" +
" openclaw mem0 init\n" +
" Get your key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=openclaw-src",
);
// Register CLI even without API key — init command must be available
// to bootstrap configuration. Pass nulls for backend/provider since
// only the init subcommand works without auth.
registerCliCommands(
api,
null as any,
null as any,
cfg,
() => cfg.userId,
(id: string) => `${cfg.userId}:agent:${id}`,
() => ({ user_id: cfg.userId, top_k: cfg.topK }),
() => undefined,
(cmd: string) => _captureEvent(`openclaw.cli.${cmd}`, { command: cmd }),
);
api.registerService({
id: "openclaw-mem0",
start: () => {
api.logger.info("openclaw-mem0: waiting for API key configuration");
},
stop: () => {},
});
return;
}
const provider = createProvider(cfg, api);
// Create Backend instance — PlatformBackend for platform mode, providerToBackend adapter for OSS
let backend: Backend;
if (cfg.mode === "platform") {
backend = new PlatformBackend({
apiKey: cfg.apiKey!,
baseUrl: cfg.baseUrl ?? "https://api.mem0.ai",
});
} else {
backend = providerToBackend(provider, cfg.userId);
}
// Shared mutable state — declared together before any closures capture them.
let currentSessionId: string | undefined;
let pluginStateDir: string | undefined;
// ========================================================================
// Per-agent isolation helpers (thin wrappers around exported functions)
// ========================================================================
const _effectiveUserId = (sessionKey?: string) =>
effectiveUserId(cfg.userId, sessionKey);
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
resolveUserId(cfg.userId, opts, currentSessionId);
const skillsActive = isSkillsMode(cfg.skills);
telemetryCtx.skillsActive = skillsActive;
_captureEvent("openclaw.plugin.registered", {
auto_recall: cfg.autoRecall,
auto_capture: cfg.autoCapture,
});
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
);
// ========================================================================
// Public Artifacts (for memory-wiki bridge mode)
// ========================================================================
if (typeof api.registerMemoryCapability === "function") {
api.registerMemoryCapability({
publicArtifacts: createPublicArtifactsProvider({
provider,
cfg,
get stateDir() {
return pluginStateDir;
},
effectiveUserId: _effectiveUserId,
}),
runtime: {
async getMemorySearchManager(_params: any) {
try {
const userId = _effectiveUserId();
let memoryCount = 0;
try {
const memories = await provider.getAll({
user_id: userId,
page_size: 1,
source: "OPENCLAW",
});
memoryCount = Array.isArray(memories) ? memories.length : 0;
} catch {
// Non-fatal: status still works without count
}
return {
manager: {
status() {
return {
backend: cfg.mode,
files: 0,
chunks: memoryCount,
dirty: false,
workspaceDir: pluginStateDir ?? "",
userId,
};
},
async probeEmbeddingAvailability() {
return { ok: true };
},
async close() {},
},
};
} catch (err) {
return {
manager: null,
error: `mem0 ${cfg.mode} backend unavailable: ${String(err)}`,
};
}
},
resolveMemoryBackendConfig(_params: any) {
return {
backend: cfg.mode,
baseUrl: cfg.baseUrl ?? "https://api.mem0.ai",
userId: cfg.userId,
};
},
async closeAllMemorySearchManagers() {},
},
});
api.logger.debug("openclaw-mem0: memory capability + runtime registered");
}
// Helper: build add options
function buildAddOptions(
userIdOverride?: string,
runId?: string,
sessionKey?: string,
): AddOptions {
// v3.0.0: removed output_format, customPrompt renamed to customInstructions
const opts: AddOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
// Pass customInstructions and customCategories to control what Mem0 extracts
if (cfg.customInstructions) opts.custom_instructions = cfg.customInstructions;
const customCategories = customCategoryMapToList(cfg.customCategories);
if (customCategories) opts.custom_categories = customCategories;
return opts;
}
// Helper: build search options (skills config overrides legacy defaults)
// v3.0.0: removed keyword_search, reranking, filter_memories, limit
function buildSearchOptions(
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
): SearchOptions {
const recallCfg = cfg.skills?.recall;
const opts: SearchOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
top_k: limit ?? cfg.topK,
threshold: recallCfg?.threshold ?? cfg.searchThreshold,
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
return opts;
}
// ========================================================================
// Tools (modular — each tool in its own file under tools/)
// ========================================================================
const toolDeps: ToolDeps = {
api,
provider,
cfg,
backend,
resolveUserId: _resolveUserId,
effectiveUserId: _effectiveUserId,
agentUserId: _agentUserId,
buildAddOptions,
buildSearchOptions,
getCurrentSessionId: () => currentSessionId,
skillsActive,
captureToolEvent: (toolName: string, props: Record<string, unknown>) => {
_captureEvent(`openclaw.tool.${toolName}`, {
tool_name: toolName,
...props,
});
},
};
registerAllTools(toolDeps);
// ========================================================================
// CLI Commands
// ========================================================================
registerCliCommands(
api,
backend,
provider,
cfg,
_effectiveUserId,
_agentUserId,
buildSearchOptions,
() => currentSessionId,
(cmd: string) => _captureEvent(`openclaw.cli.${cmd}`, { command: cmd }),
);
// ========================================================================
// Lifecycle Hooks
// ========================================================================
registerHooks(
api,
provider,
cfg,
_effectiveUserId,
buildAddOptions,
buildSearchOptions,
{
setCurrentSessionId: (id: string) => {
currentSessionId = id;
},
getStateDir: () => pluginStateDir,
},
skillsActive,
_captureEvent,
);
// ========================================================================
// Service
// ========================================================================
api.registerService({
id: "openclaw-mem0",
start: (...args: any[]) => {
pluginStateDir = args[0]?.stateDir;
api.logger.info(
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, stateDir: ${pluginStateDir ?? "none"})`,
);
},
stop: () => {
api.logger.info("openclaw-mem0: stopped");
},
});
},
});
// ============================================================================
// Lifecycle Hook Registration
// ============================================================================
function registerHooks(
api: OpenClawPluginApi,
provider: Mem0Provider,
cfg: Mem0Config,
_effectiveUserId: (sessionKey?: string) => string,
buildAddOptions: (
userIdOverride?: string,
runId?: string,
sessionKey?: string,
) => AddOptions,
buildSearchOptions: (
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
) => SearchOptions,
session: {
setCurrentSessionId: (id: string) => void;
getStateDir: () => string | undefined;
},
skillsActive: boolean = false,
_captureEvent: (
event: string,
props?: Record<string, unknown>,
) => void = () => {},
) {
// ========================================================================
// SKILLS MODE: Agentic memory via before_prompt_build
// ========================================================================
if (skillsActive) {
// Use before_prompt_build instead of before_agent_start:
// - prependSystemContext: static memory protocol (provider-cacheable, no per-turn cost)
// - prependContext: dynamic recalled memories (changes every turn)
//
// NOTE: We previously used a shared `lastCleanUserMessage` variable populated
// by message_received to get clean user content. That variable was process-global
// mutable state vulnerable to cross-session races. Removed in favor of using
// sanitizeQuery() on event.prompt within this hook, where ctx.sessionKey is
// available and the execution is scoped to the correct session.
api.on("before_prompt_build", async (event: any, ctx: any) => {
if (!event.prompt || event.prompt.length < 5) return;
const trigger = ctx?.trigger ?? undefined;
const sessionId = ctx?.sessionKey ?? undefined;
if (isNonInteractiveTrigger(trigger, sessionId)) {
api.logger.info(
"openclaw-mem0: skills-mode skipping non-interactive trigger",
);
return;
}
const promptLower = event.prompt.toLowerCase();
const isSystemPrompt =
promptLower.includes("a new session was started") ||
promptLower.includes("session startup sequence") ||
promptLower.includes("/new or /reset") ||
promptLower.startsWith("run your session");
if (isSystemPrompt) {
api.logger.info(
"openclaw-mem0: skills-mode skipping recall for system/bootstrap prompt",
);
// Still inject the protocol, just skip recall search
const systemContext = loadCompactTriagePrompt(cfg.skills ?? {});
return { prependSystemContext: systemContext };
}
if (sessionId) session.setCurrentSessionId(sessionId);
const isSubagent = isSubagentSession(sessionId);
const userId = _effectiveUserId(isSubagent ? undefined : sessionId);
// Static protocol goes in prependSystemContext (cacheable across turns)
let systemContext = loadCompactTriagePrompt(cfg.skills ?? {});
if (isSubagent) {
systemContext =
"You are a subagent — use these memories for context but do not assume you are this user. Do NOT store new memories.\n\n" +
systemContext;
}
// Dynamic recall goes in prependContext (changes every turn).
// Strategy controls how much the plugin searches automatically:
// "always" — long-term + session search every turn (2 searches)
// "smart" — long-term search only, no session search (1 search) [default]
// "manual" — no auto-recall; agent controls all search via memory_search (0 searches)
let recallContext = "";
const recallEnabled = cfg.skills?.recall?.enabled !== false;
const recallStrategy = cfg.skills?.recall?.strategy ?? "smart";
if (recallEnabled && recallStrategy !== "manual") {
const recallStart = Date.now();
try {
const query = sanitizeQuery(event.prompt);
// Smart mode: skip session search (saves 1 API call per turn)
const sessionIdForRecall =
recallStrategy === "always"
? isSubagent
? undefined
: sessionId
: undefined; // smart: long-term only
const recallResult = await skillRecall(
provider,
query,
userId,
cfg.skills ?? {},
sessionIdForRecall,
);
api.logger.info(
`openclaw-mem0: skills-mode recall (strategy=${recallStrategy}) injecting ${recallResult.memories.length} memories (~${recallResult.tokenEstimate} tokens)`,
);
_captureEvent("openclaw.hook.recall", {
strategy: recallStrategy,
memory_count: recallResult.memories.length,
latency_ms: Date.now() - recallStart,
});
recallContext = recallResult.context;
} catch (err) {
api.logger.warn(
`openclaw-mem0: skills-mode recall failed: ${String(err)}`,
);
}
} else if (recallEnabled && recallStrategy === "manual") {
api.logger.info(
"openclaw-mem0: skills-mode recall strategy=manual, agent controls search",
);
}
// 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 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;
_captureEvent("openclaw.hook.dream", {
phase: "triggered",
memory_count: memCount,
});
api.logger.info(
`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`,
);
} else {
releaseDreamLock(stateDir);
}
}
}
} catch (err) {
api.logger.warn(
`openclaw-mem0: auto-dream gate check failed: ${String(err)}`,
);
}
}
return {
prependSystemContext: systemContext, // cached by provider
prependContext: recallContext + dreamContext, // per-turn dynamic
};
});
// 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) => {
const sessionId = ctx?.sessionKey ?? undefined;
const trigger = ctx?.trigger ?? undefined;
if (sessionId) session.setCurrentSessionId(sessionId);
// 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 (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_add, memory_update, memory_delete.
// 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_add",
"memory_update",
"memory_delete",
]);
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);
_captureEvent("openclaw.hook.dream", {
phase: "completed",
write_tools_used: true,
});
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);
}
api.logger.info("openclaw-mem0: skills-mode agent_end (no auto-capture)");
});
return; // Skip legacy hook registration
}
// ========================================================================
// LEGACY MODE: Original auto-recall + auto-capture behavior
// ========================================================================
// Track last seen session ID to detect actual new sessions (not every turn)
let lastRecallSessionId: string | undefined;
// Auto-recall: inject relevant memories before prompt is built
if (cfg.autoRecall) {
const RECALL_TIMEOUT_MS = 8_000;
api.on("before_prompt_build", async (event: any, ctx: any) => {
if (!event.prompt || event.prompt.length < 5) return;
// Skip non-interactive triggers (cron, heartbeat, automation)
const trigger = (ctx as any)?.trigger ?? undefined;
const sessionId = (ctx as any)?.sessionKey ?? undefined;
if (isNonInteractiveTrigger(trigger, sessionId)) {
api.logger.info(
"openclaw-mem0: skipping recall for non-interactive trigger",
);
return;
}
const promptLower = event.prompt.toLowerCase();
const isSystemPrompt =
promptLower.includes("a new session was started") ||
promptLower.includes("session startup sequence") ||
promptLower.includes("/new or /reset") ||
promptLower.startsWith("run your session");
if (isSystemPrompt) {
api.logger.info(
"openclaw-mem0: skipping recall for system/bootstrap prompt",
);
return;
}
// Update shared state for tools (best-effort — tools don't have ctx)
if (sessionId) session.setCurrentSessionId(sessionId);
// Detect actual new session (first turn with a different sessionKey)
const isNewSession =
sessionId !== undefined && sessionId !== lastRecallSessionId;
if (sessionId) lastRecallSessionId = sessionId;
// Subagents have ephemeral UUIDs — their namespace is always empty.
// Search the parent (main) user namespace instead so subagents get
// the user's long-term context.
const isSubagent = isSubagentSession(sessionId);
const recallSessionKey = isSubagent ? undefined : sessionId;
// Strip OpenClaw sender metadata from the prompt before searching
const cleanPrompt = event.prompt
.replace(
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
"",
)
.trim();
const recallStart = Date.now();
const recallWork = async () => {
// Single search with a reasonable candidate pool
const recallTopK = Math.max((cfg.topK ?? 5) * 2, 10);
// Search long-term memories (user-scoped; subagents read from parent namespace)
let longTermResults = await provider.search(
cleanPrompt,
buildSearchOptions(
undefined,
recallTopK,
undefined,
recallSessionKey,
),
);
longTermResults = longTermResults.filter(
(r) => (r.score ?? 0) >= cfg.searchThreshold,
);
// Dynamic thresholding: drop memories scoring less than 50% of
// the top result's score to filter out the long tail of weak matches
if (longTermResults.length > 1) {
const topScore = longTermResults[0]?.score ?? 0;
if (topScore > 0) {
longTermResults = longTermResults.filter(
(r) => (r.score ?? 0) >= topScore * 0.5,
);
}
}
// Only broaden for genuinely new sessions with short prompts
// (cold-start blindness). Skip on subsequent turns to save API calls.
if (isNewSession && cleanPrompt.length < 100) {
const broadOpts = buildSearchOptions(
undefined,
5,
undefined,
recallSessionKey,
);
broadOpts.threshold = cfg.searchThreshold;
const broadResults = await provider.search(
"recent decisions, preferences, active projects, and configuration",
broadOpts,
);
const existingIds = new Set(longTermResults.map((r) => r.id));
for (const r of broadResults) {
if (!existingIds.has(r.id)) {
longTermResults.push(r);
}
}
}
// Cap at configured topK after filtering
longTermResults = longTermResults.slice(0, cfg.topK);
if (longTermResults.length === 0) return undefined;
// Build context with clear labels
const memoryContext = longTermResults
.map(
(r) =>
`- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`,
)
.join("\n");
_captureEvent("openclaw.hook.recall", {
strategy: "legacy",
memory_count: longTermResults.length,
latency_ms: Date.now() - recallStart,
});
api.logger.info(
`openclaw-mem0: injecting ${longTermResults.length} memories into context`,
);
const preamble = isSubagent
? `The following are stored memories for user "${cfg.userId}". You are a subagent — use these memories for context but do not assume you are this user.`
: `The following are stored memories for user "${cfg.userId}". Use them to personalize your response:`;
return {
prependContext: `<relevant-memories>\n${preamble}\n${memoryContext}\n</relevant-memories>`,
};
};
try {
const timeout = new Promise<undefined>((resolve) => {
setTimeout(() => resolve(undefined), RECALL_TIMEOUT_MS);
});
const result = await Promise.race([
recallWork(),
timeout.then(() => {
api.logger.warn(
`openclaw-mem0: recall timed out after ${RECALL_TIMEOUT_MS}ms, skipping`,
);
return undefined;
}),
]);
return result;
} catch (err) {
api.logger.warn(`openclaw-mem0: recall failed: ${String(err)}`);
}
});
}
// Auto-capture: store conversation context after agent ends.
if (cfg.autoCapture) {
api.on("agent_end", async (event, ctx) => {
if (!event.success || !event.messages || event.messages.length === 0) {
return;
}
// Skip non-interactive triggers (cron, heartbeat, automation)
const trigger = (ctx as any)?.trigger ?? undefined;
const sessionId = (ctx as any)?.sessionKey ?? undefined;
if (isNonInteractiveTrigger(trigger, sessionId)) {
api.logger.info(
"openclaw-mem0: skipping capture for non-interactive trigger",
);
return;
}
// Skip capture for subagents — their ephemeral UUIDs create orphaned
// namespaces that are never read again. The main agent's agent_end
// hook captures the consolidated result including subagent output.
if (isSubagentSession(sessionId)) {
api.logger.info(
"openclaw-mem0: skipping capture for subagent (main agent captures consolidated result)",
);
return;
}
// Update shared state for tools (best-effort — tools don't have ctx)
if (sessionId) session.setCurrentSessionId(sessionId);
const MEMORY_MUTATE_TOOLS = new Set([
"memory_add",
"memory_update",
"memory_delete",
]);
const agentUsedMemoryTool = event.messages.some((msg: any) => {
if (msg?.role !== "assistant" || !Array.isArray(msg?.content))
return false;
return msg.content.some(
(block: any) =>
(block?.type === "tool_use" || block?.type === "toolCall") &&
MEMORY_MUTATE_TOOLS.has(block.name),
);
});
if (agentUsedMemoryTool) {
api.logger.info(
"openclaw-mem0: skipping auto-capture — agent already used memory tools this turn",
);
return;
}
// --- Build capture payload synchronously (cheap), then fire-and-forget ---
// Patterns indicating an assistant message contains a summary of
// completed work — these are high-value for extraction and should
// be included even if they fall outside the recent-message window.
const SUMMARY_PATTERNS = [
/## What I (Accomplished|Built|Updated)/i,
/✅\s*(Done|Complete|All done)/i,
/Here's (what I updated|the recap|a summary)/i,
/### Changes Made/i,
/Implementation Status/i,
/All locked in\. Quick summary/i,
];
// First pass: extract all messages into a typed array
const allParsed: Array<{
role: string;
content: string;
index: number;
isSummary: boolean;
}> = [];
for (let i = 0; i < event.messages.length; i++) {
const msg = event.messages[i];
if (!msg || typeof msg !== "object") continue;
const msgObj = msg as Record<string, unknown>;
const role = msgObj.role;
if (role !== "user" && role !== "assistant") continue;
let textContent = "";
const content = msgObj.content;
if (typeof content === "string") {
textContent = content;
} else if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
textContent +=
(textContent ? "\n" : "") +
((block as Record<string, unknown>).text as string);
}
}
}
if (!textContent) continue;
// Strip injected memory context, keep the actual user text
if (textContent.includes("<relevant-memories>")) {
textContent = textContent
.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g, "")
.trim();
if (!textContent) continue;
}
// Strip OpenClaw sender metadata prefix (prevents storing TUI identity as memory)
if (
textContent.includes("Sender") &&
textContent.includes("untrusted metadata")
) {
textContent = textContent
.replace(
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
"",
)
.trim();
if (!textContent) continue;
}
const isSummary =
role === "assistant" &&
SUMMARY_PATTERNS.some((p) => p.test(textContent));
allParsed.push({
role: role as string,
content: textContent,
index: i,
isSummary,
});
}
if (allParsed.length === 0) return;
// Select messages: last 20 + any earlier summary messages,
// sorted by original index to preserve chronological order.
const recentWindow = 20;
const recentCutoff = allParsed.length - recentWindow;
const candidates: typeof allParsed = [];
// Include summary messages from anywhere in the conversation
for (const msg of allParsed) {
if (msg.isSummary && msg.index < recentCutoff) {
candidates.push(msg);
}
}
// Include recent messages
const seenIndices = new Set(candidates.map((m) => m.index));
for (const msg of allParsed) {
if (msg.index >= recentCutoff && !seenIndices.has(msg.index)) {
candidates.push(msg);
}
}
// Sort by original position so the extraction model sees
// messages in the order they actually occurred
candidates.sort((a, b) => a.index - b.index);
const selected = candidates.map((m) => ({
role: m.role,
content: m.content,
}));
// Apply noise filtering pipeline: drop noise, strip fragments, truncate
const formattedMessages = filterMessagesForExtraction(selected);
if (formattedMessages.length === 0) return;
// Skip if no meaningful user content remains after filtering
if (!formattedMessages.some((m) => m.role === "user")) return;
const userContent = formattedMessages
.filter((m) => m.role === "user")
.map((m) => m.content)
.join(" ");
if (userContent.length < 50) {
api.logger.info(
"openclaw-mem0: skipping capture — user content too short for meaningful extraction",
);
return;
}
// Inject a timestamp preamble so the extraction model can anchor
// time-sensitive facts to a concrete date and attribute to the correct user
const timestamp = new Date().toISOString().split("T")[0];
formattedMessages.unshift({
role: "system",
content: `Current date: ${timestamp}. The user is identified as "${cfg.userId}". Extract durable facts from this conversation. Include this date when storing time-sensitive information.`,
});
const addOpts = buildAddOptions(undefined, undefined, sessionId);
const captureStart = Date.now();
provider
.add(formattedMessages, addOpts)
.then((result) => {
const capturedCount = result.results?.length ?? 0;
_captureEvent("openclaw.hook.capture", {
captured_count: capturedCount,
latency_ms: Date.now() - captureStart,
});
if (capturedCount > 0) {
api.logger.info(
`openclaw-mem0: auto-captured ${capturedCount} memories`,
);
}
})
.catch((err) => {
api.logger.warn(`openclaw-mem0: capture failed: ${String(err)}`);
});
});
}
}
export default memoryPlugin;