From 396cdb175b416a846155b1985ca3a85a4f09d104 Mon Sep 17 00:00:00 2001
From: Lcos <3349888061@qq.com>
Date: Sun, 26 Jul 2026 10:52:01 +0800
Subject: [PATCH] feat(memory): add Mem0 as an optional external plugin memory
provider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extend the plugin memory SPI with a three-arg prefetch(agentId, userQuery, ownerKey) default method and forward ownerKey through PluginMemoryBridge, enabling per-owner isolated recall for external providers. Ship mateclaw-plugin-mem0: an optional, zero-extra-dependency plugin that bridges a self-hosted Mem0 service (semantic recall via /memories/search/, async turn sync via /memories/) with full fault isolation — not part of the default stack. Includes 42 tests and bilingual user docs.
---
.../api/memory/PluginMemoryProvider.java | 18 ++
mateclaw-plugin-mem0/pom.xml | 74 ++++++
.../java/vip/mate/plugin/mem0/Mem0Client.java | 182 ++++++++++++++
.../java/vip/mate/plugin/mem0/Mem0Config.java | 47 ++++
.../vip/mate/plugin/mem0/Mem0Exception.java | 21 ++
.../java/vip/mate/plugin/mem0/Mem0Plugin.java | 105 +++++++++
.../vip/mate/plugin/mem0/Mem0Provider.java | 171 ++++++++++++++
.../src/main/resources/mateclaw-plugin.json | 48 ++++
.../vip/mate/plugin/mem0/Mem0ClientTest.java | 163 +++++++++++++
.../vip/mate/plugin/mem0/Mem0ConfigTest.java | 38 +++
.../vip/mate/plugin/mem0/Mem0PluginTest.java | 141 +++++++++++
.../mate/plugin/mem0/Mem0ProviderTest.java | 194 +++++++++++++++
.../plugin/bridge/PluginMemoryBridge.java | 6 +
.../src/main/resources/docs/en/memory.md | 58 +++++
.../src/main/resources/docs/zh/memory.md | 58 +++++
.../MemoryManagerPluginPrefetchTest.java | 223 ++++++++++++++++++
.../plugin/bridge/PluginMemoryBridgeTest.java | 207 ++++++++++++++++
pom.xml | 1 +
18 files changed, 1755 insertions(+)
create mode 100644 mateclaw-plugin-mem0/pom.xml
create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java
create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java
create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java
create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java
create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java
create mode 100644 mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json
create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java
create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java
create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java
create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerPluginPrefetchTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginMemoryBridgeTest.java
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
index 2474d585..128c185a 100644
--- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
@@ -54,6 +54,24 @@ public interface PluginMemoryProvider {
return "";
}
+ /**
+ * Pre-turn context recall with per-owner isolation. Called by the platform
+ * when an owner key (e.g. {@code "user:42"}, {@code "feishu:sender_abc"})
+ * is resolved for the current conversation.
+ *
+ * Default implementation degrades to the two-arg variant, dropping the
+ * owner key. External providers that need per-owner recall (e.g. Mem0)
+ * should override this to use {@code ownerKey} as their per-user identifier.
+ *
+ * @param agentId the agent ID
+ * @param userQuery the current user message
+ * @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown
+ * @return context text to inject, or empty string
+ */
+ default String prefetch(Long agentId, String userQuery, String ownerKey) {
+ return prefetch(agentId, userQuery);
+ }
+
/**
* Post-turn sync. Called after LLM response is available.
* Should be non-blocking (async).
diff --git a/mateclaw-plugin-mem0/pom.xml b/mateclaw-plugin-mem0/pom.xml
new file mode 100644
index 00000000..d9c75a1e
--- /dev/null
+++ b/mateclaw-plugin-mem0/pom.xml
@@ -0,0 +1,74 @@
+
+
+ 4.0.0
+
+
+ vip.mate
+ mateclaw
+ ${revision}
+ ../pom.xml
+
+
+ mateclaw-plugin-mem0
+ jar
+
+ MateClaw Mem0 Memory Provider Plugin
+
+ Optional community plugin that bridges MateClaw's memory system to a self-hosted
+ Mem0 service (FastAPI + pgvector + Neo4j). Provides semantic recall via Mem0's
+ REST API alongside the built-in local memory providers. Not in the default stack;
+ users must deploy Mem0 separately and install this JAR into the plugins/ directory.
+
+
+
+
+
+ vip.mate
+ mateclaw-plugin-api
+ provided
+
+
+
+
+ org.springframework.ai
+ spring-ai-model
+ provided
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ provided
+
+
+
+
+ org.slf4j
+ slf4j-api
+ provided
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+ org.slf4j
+ slf4j-simple
+ test
+
+
+
diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java
new file mode 100644
index 00000000..9c64c758
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java
@@ -0,0 +1,182 @@
+package vip.mate.plugin.mem0;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Thin HTTP client for a self-hosted Mem0 REST API.
+ *
+ * Covers the two endpoints used by {@link Mem0Provider}:
+ *
+ *
{@code POST /memories/} — add a turn (user + assistant message) for extraction
+ *
{@code POST /memories/search/} — semantic recall by query + user_id
+ *
+ *
+ *
Failure semantics: every call either returns a parsed result or throws
+ * {@link Mem0Exception}. Callers are expected to catch and degrade gracefully
+ * (return empty recall / log sync failures).
+ *
+ * @author MateClaw Team
+ */
+class Mem0Client {
+
+ private final Mem0Config config;
+ private final HttpClient http;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ Mem0Client(Mem0Config config) {
+ this.config = config;
+ this.http = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofMillis(config.timeoutMs()))
+ .build();
+ }
+
+ /**
+ * Push a conversation turn to Mem0 for extraction.
+ *
+ * @param userId Mem0 user_id, typically MateClaw's ownerKey
+ * @param agentId Mem0 agent_id, typically MateClaw's agentId
+ * @param conversationId optional conversation identifier (stored as metadata)
+ * @param userMessage user's message text
+ * @param assistantReply assistant's reply text
+ */
+ void addMemories(String userId, String agentId, String conversationId,
+ String userMessage, String assistantReply) {
+ ObjectNode body = mapper.createObjectNode();
+ body.put("user_id", userId);
+ if (agentId != null && !agentId.isBlank()) {
+ body.put("agent_id", agentId);
+ }
+ ArrayNode messages = body.putArray("messages");
+ if (userMessage != null && !userMessage.isBlank()) {
+ ObjectNode m = messages.addObject();
+ m.put("role", "user");
+ m.put("content", userMessage);
+ }
+ if (assistantReply != null && !assistantReply.isBlank()) {
+ ObjectNode m = messages.addObject();
+ m.put("role", "assistant");
+ m.put("content", assistantReply);
+ }
+ if (conversationId != null && !conversationId.isBlank()) {
+ ObjectNode meta = body.putObject("metadata");
+ meta.put("conversation_id", conversationId);
+ }
+
+ post("/memories/", body);
+ }
+
+ /**
+ * Semantic recall.
+ *
+ * @param userId Mem0 user_id (ownerKey)
+ * @param agentId Mem0 agent_id
+ * @param query user query text
+ * @return list of memory strings, possibly empty; never null
+ */
+ List searchMemories(String userId, String agentId, String query) {
+ ObjectNode body = mapper.createObjectNode();
+ body.put("query", query);
+ body.put("user_id", userId);
+ if (agentId != null && !agentId.isBlank()) {
+ body.put("agent_id", agentId);
+ }
+ body.put("limit", config.maxResults());
+
+ JsonNode resp = post("/memories/search/", body);
+ JsonNode results = resp.path("results");
+ List out = new ArrayList<>();
+ if (results.isArray()) {
+ for (JsonNode r : results) {
+ String mem = r.path("memory").asText("");
+ if (!mem.isBlank()) {
+ out.add(mem);
+ }
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Shared POST helper. Returns the parsed JSON body on 2xx.
+ *
+ * @throws Mem0Exception on non-2xx response or IO error
+ */
+ private JsonNode post(String path, ObjectNode body) {
+ String url = config.normalizedBaseUrl() + path;
+ try {
+ String payload = mapper.writeValueAsString(body);
+ HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
+ .timeout(Duration.ofMillis(config.timeoutMs()))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(payload));
+ if (config.apiKey() != null && !config.apiKey().isBlank()) {
+ req.header("Authorization", "Bearer " + config.apiKey());
+ }
+
+ HttpResponse resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
+ int code = resp.statusCode();
+ if (code < 200 || code >= 300) {
+ throw new Mem0Exception("Mem0 " + path + " returned HTTP " + code
+ + ": " + truncate(resp.body(), 500));
+ }
+ return mapper.readTree(resp.body() == null ? "{}" : resp.body());
+ } catch (Mem0Exception e) {
+ throw e;
+ } catch (Exception e) {
+ throw new Mem0Exception("Mem0 " + path + " request failed: " + e.getMessage(), e);
+ }
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) return "";
+ return s.length() > max ? s.substring(0, max) + "..." : s;
+ }
+
+ /**
+ * Test-only accessor for verifying configuration wiring.
+ */
+ Mem0Config config() {
+ return config;
+ }
+
+ /**
+ * Test-only helper to inspect what would be POSTed without sending.
+ * Builds the same payload as {@link #addMemories} and returns it as a Map.
+ */
+ Map buildAddPayload(String userId, String agentId, String conversationId,
+ String userMessage, String assistantReply) {
+ ObjectNode body = mapper.createObjectNode();
+ body.put("user_id", userId);
+ if (agentId != null && !agentId.isBlank()) {
+ body.put("agent_id", agentId);
+ }
+ ArrayNode messages = body.putArray("messages");
+ if (userMessage != null && !userMessage.isBlank()) {
+ ObjectNode m = messages.addObject();
+ m.put("role", "user");
+ m.put("content", userMessage);
+ }
+ if (assistantReply != null && !assistantReply.isBlank()) {
+ ObjectNode m = messages.addObject();
+ m.put("role", "assistant");
+ m.put("content", assistantReply);
+ }
+ if (conversationId != null && !conversationId.isBlank()) {
+ ObjectNode meta = body.putObject("metadata");
+ meta.put("conversation_id", conversationId);
+ }
+ return mapper.convertValue(body, Map.class);
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java
new file mode 100644
index 00000000..ea7ae20e
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java
@@ -0,0 +1,47 @@
+package vip.mate.plugin.mem0;
+
+/**
+ * Mem0 plugin configuration snapshot.
+ *
+ * Read once from {@link vip.mate.plugin.api.PluginContext#getConfig} at plugin
+ * load time and passed to {@link Mem0Client} / {@link Mem0Provider}. Snapshot
+ * semantics — config changes require a plugin reload.
+ *
+ * @param baseUrl Mem0 REST API base URL, e.g. {@code http://localhost:8080}
+ * @param apiKey optional bearer token; null/blank means no Authorization header
+ * @param searchEnabled whether prefetch should query Mem0 /memories/search/
+ * @param syncEnabled whether syncTurn should POST to Mem0 /memories/
+ * @param maxResults cap on memories returned per recall
+ * @param timeoutMs HTTP timeout for both recall and sync
+ * @author MateClaw Team
+ */
+record Mem0Config(
+ String baseUrl,
+ String apiKey,
+ boolean searchEnabled,
+ boolean syncEnabled,
+ int maxResults,
+ int timeoutMs
+) {
+ static final int DEFAULT_MAX_RESULTS = 5;
+ static final int DEFAULT_TIMEOUT_MS = 3000;
+
+ /**
+ * Whether this provider should participate at all.
+ * Mem0 without a base URL is unusable; treat as unavailable.
+ */
+ boolean isUsable() {
+ return baseUrl != null && !baseUrl.isBlank();
+ }
+
+ /**
+ * Strip trailing slashes from the base URL to avoid double-slash in path joins.
+ */
+ String normalizedBaseUrl() {
+ String url = baseUrl;
+ while (url.endsWith("/")) {
+ url = url.substring(0, url.length() - 1);
+ }
+ return url;
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java
new file mode 100644
index 00000000..064fffa3
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java
@@ -0,0 +1,21 @@
+package vip.mate.plugin.mem0;
+
+/**
+ * Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
+ *
+ * Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade
+ * gracefully (empty recall / dropped sync) without affecting the agent's
+ * response path.
+ *
+ * @author MateClaw Team
+ */
+class Mem0Exception extends RuntimeException {
+
+ Mem0Exception(String message) {
+ super(message);
+ }
+
+ Mem0Exception(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java
new file mode 100644
index 00000000..f0454c67
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java
@@ -0,0 +1,105 @@
+package vip.mate.plugin.mem0;
+
+import org.slf4j.Logger;
+import vip.mate.plugin.api.MateClawPlugin;
+import vip.mate.plugin.api.PluginContext;
+
+/**
+ * MateClaw plugin entrypoint that registers {@link Mem0Provider} with the
+ * platform's memory subsystem.
+ *
+ * Lifecycle:
+ *
+ *
{@code onLoad} — read config from {@link PluginContext}, build
+ * {@link Mem0Config} → {@link Mem0Client} → {@link Mem0Provider},
+ * then {@code context.registerMemoryProvider(provider)}.
+ * If the config is incomplete (no baseUrl), the provider is registered
+ * but reports {@code isAvailable()=false} — the platform silently
+ * skips it.
{@code systemPromptBlock} — no-op (returns ""), aligns with SessionSearchProvider
+ *
{@code prefetch(agentId, query, ownerKey)} — when {@code searchEnabled}
+ * and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
+ * and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
+ * or when disabled.
+ *
{@code syncTurn} — when {@code syncEnabled} and {@code ownerKey} is
+ * non-blank, asynchronously pushes the turn to {@code POST /memories/}.
+ * Failures are logged and swallowed; never blocks the response path.
+ *
{@code getToolBeans} — empty (no agent-facing tools in v1)
+ *
+ *
+ *
Per-owner isolation: {@code ownerKey} (e.g. {@code "user:42"}) is passed
+ * verbatim as Mem0's {@code user_id}; {@code agentId} as Mem0's {@code agent_id}.
+ * When {@code ownerKey} is null/blank, both recall and sync are skipped — Mem0
+ * requires {@code user_id}.
+ *
+ *
Asynchronous sync: a single-thread virtual-thread-per-task executor is used
+ * so that bursts of turns don't pile up on the platform's request thread.
+ *
+ * @author MateClaw Team
+ */
+class Mem0Provider implements PluginMemoryProvider {
+
+ static final String ID = "mem0";
+
+ private final Mem0Config config;
+ private final Mem0Client client;
+ private final Logger log;
+ private final Executor async;
+
+ Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
+ this.config = config;
+ this.client = client;
+ this.log = log;
+ // Single-thread executor is enough — syncTurn calls are sequential per
+ // agent and not latency-sensitive; the platform's request thread must
+ // not be blocked. A bounded single-thread queue keeps memory footprint
+ // predictable even under burst load.
+ this.async = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "mem0-sync");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public int order() {
+ // Same as the SPI default; declared explicitly for clarity.
+ return 200;
+ }
+
+ @Override
+ public boolean isAvailable() {
+ // Provider is "available" if at least one of recall/sync can fire.
+ return config.isUsable() && (config.searchEnabled() || config.syncEnabled());
+ }
+
+ @Override
+ public String systemPromptBlock(Long agentId) {
+ return "";
+ }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery) {
+ // Two-arg variant: no owner key → cannot isolate per-user → skip.
+ // Mem0 requires user_id; without it the call would either fail or
+ // return global memories breaking per-owner isolation.
+ return "";
+ }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ if (!config.searchEnabled()) {
+ return "";
+ }
+ if (ownerKey == null || ownerKey.isBlank()) {
+ return "";
+ }
+ if (userQuery == null || userQuery.isBlank()) {
+ return "";
+ }
+ try {
+ List memories = client.searchMemories(
+ ownerKey, agentId == null ? null : agentId.toString(), userQuery);
+ if (memories.isEmpty()) {
+ return "";
+ }
+ return formatRecallBlock(memories);
+ } catch (Exception e) {
+ // Fault isolation: log and return empty so the platform falls back
+ // to the other (local) providers without affecting the response.
+ log.warn("[Mem0] prefetch failed for agent={} owner={}: {}",
+ agentId, ownerKey, e.getMessage());
+ return "";
+ }
+ }
+
+ @Override
+ public void syncTurn(Long agentId, String conversationId,
+ String userMessage, String assistantReply) {
+ if (!config.syncEnabled()) {
+ return;
+ }
+ // ownerKey is NOT available in the two-arg syncTurn signature
+ // (PlatformMemoryProvider only passes agentId + conversationId + messages).
+ // We push the turn using agentId as the user_id fallback — this is
+ // weaker isolation than prefetch (which has ownerKey), but better than
+ // dropping the turn. If users need strict per-owner sync, configure
+ // syncEnabled=false and rely on prefetch-only recall.
+ // NOTE: this is a known v1 limitation; a future SPI extension would
+ // pass ownerKey into syncTurn as well.
+ String userId = agentId == null ? null : agentId.toString();
+ if (userId == null || userId.isBlank()) {
+ return;
+ }
+ if ((userMessage == null || userMessage.isBlank())
+ && (assistantReply == null || assistantReply.isBlank())) {
+ return;
+ }
+ CompletableFuture.runAsync(() -> {
+ try {
+ client.addMemories(userId, agentId == null ? null : agentId.toString(),
+ conversationId, userMessage, assistantReply);
+ } catch (Exception e) {
+ log.debug("[Mem0] syncTurn failed for agent={}: {}", agentId, e.getMessage());
+ }
+ }, async);
+ }
+
+ @Override
+ public void onSessionEnd(Long agentId, String conversationId) {
+ // No Mem0-specific session cleanup needed in v1.
+ }
+
+ /**
+ * Format the recalled memories into a labeled block.
+ *
+ * The {@code [Mem0 Recall]} label is intentional: it lets the LLM
+ * distinguish this block from the local providers' output and avoid
+ * treating it as authoritative PROFILE.md content.
+ */
+ private String formatRecallBlock(List memories) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("[Mem0 Recall — semantic matches from external service, treat as hints]\n");
+ for (int i = 0; i < memories.size(); i++) {
+ sb.append(i + 1).append(". ").append(memories.get(i)).append('\n');
+ }
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json
new file mode 100644
index 00000000..a0e974a9
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json
@@ -0,0 +1,48 @@
+{
+ "name": "mateclaw-plugin-mem0",
+ "version": "1.0.0",
+ "type": "memory",
+ "displayName": "Mem0 Memory Provider (Optional)",
+ "description": "Bridges MateClaw's memory system to a self-hosted Mem0 service. Adds semantic recall from Mem0 alongside the built-in local memory providers. Requires a separately deployed Mem0 service (FastAPI + pgvector). Not part of the default stack.",
+ "entrypoint": "vip.mate.plugin.mem0.Mem0Plugin",
+ "minPlatformVersion": "2.0.0",
+ "author": "MateClaw Team",
+ "config": {
+ "baseUrl": {
+ "type": "string",
+ "required": true,
+ "secret": false,
+ "description": "Mem0 REST API base URL, e.g. http://localhost:8080"
+ },
+ "apiKey": {
+ "type": "string",
+ "required": false,
+ "secret": true,
+ "description": "Optional bearer token sent as Authorization header to Mem0"
+ },
+ "searchEnabled": {
+ "type": "boolean",
+ "required": false,
+ "secret": false,
+ "description": "Enable semantic recall via Mem0 /memories/search/. Default true."
+ },
+ "syncEnabled": {
+ "type": "boolean",
+ "required": false,
+ "secret": false,
+ "description": "Enable pushing each turn to Mem0 /memories/. Default true."
+ },
+ "maxResults": {
+ "type": "integer",
+ "required": false,
+ "secret": false,
+ "description": "Max number of memories returned per recall. Default 5."
+ },
+ "timeoutMs": {
+ "type": "integer",
+ "required": false,
+ "secret": false,
+ "description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
+ }
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java
new file mode 100644
index 00000000..b39df290
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java
@@ -0,0 +1,163 @@
+package vip.mate.plugin.mem0;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class Mem0ClientTest {
+
+ private HttpServer server;
+ private Mem0Client client;
+ private final AtomicReference lastPath = new AtomicReference<>();
+ private final AtomicReference lastBody = new AtomicReference<>();
+ private final AtomicReference lastAuthHeader = new AtomicReference<>();
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @BeforeEach
+ void setUp() throws IOException {
+ // Capture request details so each test can assert what was sent.
+ HttpHandler handler = this::handle;
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", handler);
+ server.start();
+
+ String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
+ Mem0Config config = new Mem0Config(baseUrl, "test-token", true, true, 5, 3000);
+ client = new Mem0Client(config);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) server.stop(0);
+ }
+
+ private void handle(HttpExchange exchange) throws IOException {
+ lastPath.set(exchange.getRequestURI().getPath());
+ lastAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization"));
+ try (InputStream in = exchange.getRequestBody()) {
+ lastBody.set(new String(in.readAllBytes(), StandardCharsets.UTF_8));
+ }
+ String path = exchange.getRequestURI().getPath();
+ if ("/memories/".equals(path) || "/memories/search/".equals(path)) {
+ byte[] resp;
+ if ("/memories/".equals(path)) {
+ resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"x\",\"event\":\"ADD\"}]}".getBytes(StandardCharsets.UTF_8);
+ } else {
+ resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes Go\",\"score\":0.9},{\"id\":\"m2\",\"memory\":\"works at Acme\",\"score\":0.7}]}".getBytes(StandardCharsets.UTF_8);
+ }
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ } else {
+ byte[] resp = "{\"error\":\"not found\"}".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(404, resp.length);
+ exchange.getResponseBody().write(resp);
+ }
+ exchange.close();
+ }
+
+ @Test
+ void addMemories_postsToMemoriesEndpointWithCorrectPayload() throws Exception {
+ client.addMemories("user:42", "1", "conv-abc", "hello", "world");
+
+ assertThat(lastPath.get()).isEqualTo("/memories/");
+ assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token");
+
+ JsonNode body = mapper.readTree(lastBody.get());
+ assertThat(body.get("user_id").asText()).isEqualTo("user:42");
+ assertThat(body.get("agent_id").asText()).isEqualTo("1");
+ assertThat(body.get("metadata").get("conversation_id").asText()).isEqualTo("conv-abc");
+ assertThat(body.get("messages").size()).isEqualTo(2);
+ assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("user");
+ assertThat(body.get("messages").get(0).get("content").asText()).isEqualTo("hello");
+ assertThat(body.get("messages").get(1).get("role").asText()).isEqualTo("assistant");
+ assertThat(body.get("messages").get(1).get("content").asText()).isEqualTo("world");
+ }
+
+ @Test
+ void addMemories_omitsBlankMessages() throws Exception {
+ client.addMemories("user:42", "1", null, " ", "reply");
+
+ JsonNode body = mapper.readTree(lastBody.get());
+ assertThat(body.get("messages").size()).isEqualTo(1);
+ assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("assistant");
+ // metadata should be absent since conversationId is null
+ assertThat(body.has("metadata")).isFalse();
+ }
+
+ @Test
+ void searchMemories_returnsParsedMemoryStrings() {
+ List results = client.searchMemories("user:42", "1", "what language");
+
+ assertThat(results).containsExactly("likes Go", "works at Acme");
+
+ assertThat(lastPath.get()).isEqualTo("/memories/search/");
+ assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token");
+ }
+
+ @Test
+ void searchMemories_includesQueryUserIdAndLimitInBody() throws Exception {
+ client.searchMemories("user:42", "1", "query text");
+
+ JsonNode body = mapper.readTree(lastBody.get());
+ assertThat(body.get("query").asText()).isEqualTo("query text");
+ assertThat(body.get("user_id").asText()).isEqualTo("user:42");
+ assertThat(body.get("agent_id").asText()).isEqualTo("1");
+ assertThat(body.get("limit").asInt()).isEqualTo(5); // from Mem0Config in setUp
+ }
+
+ @Test
+ void non2xxResponseThrowsMem0Exception() {
+ // Use a client pointed at a non-existent path on the running server.
+ // Reconfigure handler to return 500 for the next call.
+ server.removeContext("/");
+ server.createContext("/", ex -> {
+ ex.sendResponseHeaders(500, 0);
+ ex.close();
+ });
+
+ assertThatThrownBy(() -> client.searchMemories("user:42", "1", "q"))
+ .isInstanceOf(Mem0Exception.class)
+ .hasMessageContaining("HTTP 500");
+ }
+
+ @Test
+ void connectionFailureThrowsMem0Exception() {
+ // Stop the server, then call — should fail with connection refused.
+ int port = server.getAddress().getPort();
+ server.stop(0);
+ Mem0Config cfg = new Mem0Config("http://127.0.0.1:" + port, null, true, true, 5, 500);
+ Mem0Client deadClient = new Mem0Client(cfg);
+
+ assertThatThrownBy(() -> deadClient.searchMemories("user:42", "1", "q"))
+ .isInstanceOf(Mem0Exception.class)
+ .hasMessageContaining("request failed");
+ }
+
+ @Test
+ void buildAddPayload_isConsistentWithAddMemories() {
+ // buildAddPayload is a test helper used to inspect payload structure
+ // without sending; verify it matches what addMemories would send.
+ Map payload = client.buildAddPayload("user:42", "1", "conv-x", "hi", "there");
+ assertThat(payload).containsEntry("user_id", "user:42");
+ assertThat(payload).containsEntry("agent_id", "1");
+ assertThat(payload).containsKey("messages");
+ assertThat(payload).containsKey("metadata");
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java
new file mode 100644
index 00000000..f1d0a279
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java
@@ -0,0 +1,38 @@
+package vip.mate.plugin.mem0;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class Mem0ConfigTest {
+
+ @Test
+ void isUsable_false_whenBaseUrlNull() {
+ Mem0Config c = new Mem0Config(null, null, true, true, 5, 1000);
+ assertThat(c.isUsable()).isFalse();
+ }
+
+ @Test
+ void isUsable_false_whenBaseUrlBlank() {
+ Mem0Config c = new Mem0Config(" ", null, true, true, 5, 1000);
+ assertThat(c.isUsable()).isFalse();
+ }
+
+ @Test
+ void isUsable_true_whenBaseUrlSet() {
+ Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
+ assertThat(c.isUsable()).isTrue();
+ }
+
+ @Test
+ void normalizedBaseUrl_stripsTrailingSlashes() {
+ Mem0Config c = new Mem0Config("http://localhost:8080///", null, true, true, 5, 1000);
+ assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
+ }
+
+ @Test
+ void normalizedBaseUrl_keepsUrlWithoutTrailingSlash() {
+ Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
+ assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java
new file mode 100644
index 00000000..3be42abc
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java
@@ -0,0 +1,141 @@
+package vip.mate.plugin.mem0;
+
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import vip.mate.plugin.api.PluginContext;
+import vip.mate.plugin.api.PluginException;
+import vip.mate.plugin.api.memory.PluginMemoryProvider;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class Mem0PluginTest {
+
+ @Test
+ void onLoad_readsConfigAndRegistersProvider() {
+ Map config = new HashMap<>();
+ config.put("baseUrl", "http://localhost:8080");
+ config.put("apiKey", "secret");
+ config.put("searchEnabled", true);
+ config.put("syncEnabled", false);
+ config.put("maxResults", 7);
+ config.put("timeoutMs", 5000);
+
+ AtomicReference registered = new AtomicReference<>();
+ PluginContext ctx = new StubContext(config, registered);
+
+ Mem0Plugin plugin = new Mem0Plugin();
+ plugin.onLoad(ctx);
+ plugin.onEnable();
+
+ PluginMemoryProvider p = registered.get();
+ assertThat(p).isNotNull();
+ assertThat(p.id()).isEqualTo("mem0");
+ assertThat(p.isAvailable()).isTrue(); // baseUrl set + searchEnabled true
+
+ plugin.onDisable();
+ }
+
+ @Test
+ void onLoad_withMissingBaseUrl_stillRegistersButUnavailable() {
+ // No baseUrl configured — plugin should register but report unavailable
+ // rather than throwing.
+ Map config = new HashMap<>(); // empty
+ AtomicReference registered = new AtomicReference<>();
+ PluginContext ctx = new StubContext(config, registered);
+
+ Mem0Plugin plugin = new Mem0Plugin();
+ plugin.onLoad(ctx);
+
+ PluginMemoryProvider p = registered.get();
+ assertThat(p).isNotNull();
+ assertThat(p.isAvailable()).isFalse();
+ }
+
+ @Test
+ void onLoad_appliesDefaultsToOptionalConfig() {
+ // Only baseUrl set — searchEnabled/syncEnabled/maxResults/timeoutMs
+ // should default.
+ Map config = new HashMap<>();
+ config.put("baseUrl", "http://localhost:8080");
+
+ AtomicReference registered = new AtomicReference<>();
+ PluginContext ctx = new StubContext(config, registered);
+
+ Mem0Plugin plugin = new Mem0Plugin();
+ plugin.onLoad(ctx);
+
+ // Verify defaults indirectly: searchEnabled and syncEnabled both default
+ // to true → isAvailable() must be true.
+ assertThat(registered.get().isAvailable()).isTrue();
+ }
+
+ @Test
+ void onLoad_throwsWhenContextRejectsSecondProvider() {
+ // Simulate the platform's single-select constraint by throwing from
+ // registerMemoryProvider.
+ Map config = new HashMap<>();
+ config.put("baseUrl", "http://localhost:8080");
+ AtomicReference registered = new AtomicReference<>();
+ PluginContext ctx = new StubContext(config, registered) {
+ @Override
+ public void registerMemoryProvider(PluginMemoryProvider provider) {
+ throw new PluginException("Only one external memory provider allowed");
+ }
+ };
+
+ Mem0Plugin plugin = new Mem0Plugin();
+ assertThatThrownBy(() -> plugin.onLoad(ctx))
+ .isInstanceOf(PluginException.class)
+ .hasMessageContaining("Only one");
+ }
+
+ /**
+ * Minimal PluginContext stub: only getConfig / registerMemoryProvider /
+ * getLogger are exercised by Mem0Plugin; everything else throws.
+ */
+ static class StubContext implements PluginContext {
+ private final Map config;
+ private final AtomicReference registered;
+
+ StubContext(Map config, AtomicReference registered) {
+ this.config = config;
+ this.registered = registered;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getConfig(String key, Class type) {
+ Object v = config.get(key);
+ if (v == null) return null;
+ if (type.isInstance(v)) return (T) v;
+ // Best-effort scalar coercion for Integer/Boolean from String/Number
+ if (type == Integer.class && v instanceof Number n) return (T) (Integer) n.intValue();
+ if (type == Boolean.class && v instanceof Boolean b) return (T) b;
+ return null;
+ }
+
+ @Override
+ public Logger getLogger() {
+ return LoggerFactory.getLogger("test.Mem0Plugin");
+ }
+
+ @Override
+ public void registerMemoryProvider(PluginMemoryProvider provider) {
+ registered.set(provider);
+ }
+
+ // The remaining methods are not used by Mem0Plugin; stub them out.
+
+ @Override public void registerTool(org.springframework.ai.tool.ToolCallback tool) { throw new UnsupportedOperationException(); }
+ @Override public void registerTool(org.springframework.ai.tool.ToolCallback tool, java.util.function.Supplier availabilityCheck) { throw new UnsupportedOperationException(); }
+ @Override public void registerProvider(String providerId, org.springframework.ai.chat.model.ChatModel chatModel) { throw new UnsupportedOperationException(); }
+ @Override public void registerChannel(vip.mate.plugin.api.channel.PluginChannelAdapter channel) { throw new UnsupportedOperationException(); }
+ @Override public void registerSearchProvider(vip.mate.plugin.api.search.PluginSearchProvider provider) { throw new UnsupportedOperationException(); }
+ }
+}
diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java
new file mode 100644
index 00000000..615ca6ad
--- /dev/null
+++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java
@@ -0,0 +1,194 @@
+package vip.mate.plugin.mem0;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class Mem0ProviderTest {
+
+ private HttpServer server;
+ private Mem0Provider provider;
+ private final AtomicInteger addCount = new AtomicInteger();
+ private final AtomicInteger searchCount = new AtomicInteger();
+
+ @BeforeEach
+ void setUp() throws IOException {
+ addCount.set(0);
+ searchCount.set(0);
+ HttpHandler handler = this::handle;
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", handler);
+ server.start();
+
+ String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
+ Mem0Config config = new Mem0Config(baseUrl, null, true, true, 3, 3000);
+ Mem0Client client = new Mem0Client(config);
+ provider = new Mem0Provider(config, client, LoggerFactory.getLogger("test"));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) server.stop(0);
+ }
+
+ private void handle(HttpExchange exchange) throws IOException {
+ try (InputStream in = exchange.getRequestBody()) {
+ in.readAllBytes(); // drain
+ }
+ String path = exchange.getRequestURI().getPath();
+ byte[] resp;
+ if ("/memories/".equals(path)) {
+ addCount.incrementAndGet();
+ resp = "{\"results\":[]}".getBytes(StandardCharsets.UTF_8);
+ } else if ("/memories/search/".equals(path)) {
+ searchCount.incrementAndGet();
+ resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes PostgreSQL\",\"score\":0.9}]}".getBytes(StandardCharsets.UTF_8);
+ } else {
+ resp = "{}".getBytes(StandardCharsets.UTF_8);
+ }
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ exchange.close();
+ }
+
+ @Test
+ void id_isMem0() {
+ assertThat(provider.id()).isEqualTo("mem0");
+ }
+
+ @Test
+ void isAvailable_true_whenConfigUsableAndAtLeastOneFeatureEnabled() {
+ assertThat(provider.isAvailable()).isTrue();
+ }
+
+ @Test
+ void isAvailable_false_whenBaseUrlMissing() {
+ Mem0Config cfg = new Mem0Config(null, null, true, true, 5, 1000);
+ Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
+ assertThat(p.isAvailable()).isFalse();
+ }
+
+ @Test
+ void isAvailable_false_whenBothFeaturesDisabled() {
+ Mem0Config cfg = new Mem0Config("http://localhost:8080", null, false, false, 5, 1000);
+ Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
+ assertThat(p.isAvailable()).isFalse();
+ }
+
+ @Test
+ void systemPromptBlock_isEmpty() {
+ assertThat(provider.systemPromptBlock(1L)).isEmpty();
+ }
+
+ @Test
+ void twoArgPrefetch_returnsEmptyBecauseNoOwnerKey() {
+ // Without ownerKey, Mem0 cannot isolate per-user; provider skips.
+ assertThat(provider.prefetch(1L, "hello")).isEmpty();
+ assertThat(searchCount.get()).isZero();
+ }
+
+ @Test
+ void threeArgPrefetch_returnsRecallBlock() {
+ String result = provider.prefetch(1L, "what database", "user:42");
+
+ assertThat(result).startsWith("[Mem0 Recall");
+ assertThat(result).contains("likes PostgreSQL");
+ assertThat(searchCount.get()).isEqualTo(1);
+ }
+
+ @Test
+ void threeArgPrefetch_returnsEmptyWhenOwnerKeyBlank() {
+ assertThat(provider.prefetch(1L, "query", "")).isEmpty();
+ assertThat(provider.prefetch(1L, "query", null)).isEmpty();
+ assertThat(searchCount.get()).isZero();
+ }
+
+ @Test
+ void threeArgPrefetch_returnsEmptyWhenQueryBlank() {
+ assertThat(provider.prefetch(1L, "", "user:42")).isEmpty();
+ assertThat(provider.prefetch(1L, null, "user:42")).isEmpty();
+ assertThat(searchCount.get()).isZero();
+ }
+
+ @Test
+ void threeArgPrefetch_returnsEmptyOnServerError() {
+ // Replace handler to fail; the provider should swallow and return "".
+ server.removeContext("/");
+ server.createContext("/", ex -> {
+ ex.sendResponseHeaders(500, 0);
+ ex.close();
+ });
+
+ String result = provider.prefetch(1L, "q", "user:42");
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void syncTurn_pushesAsynchronouslyWithoutBlocking() throws Exception {
+ provider.syncTurn(1L, "conv-1", "hello", "world");
+
+ // Wait briefly for the virtual thread to fire the POST.
+ long deadline = System.currentTimeMillis() + 2000;
+ while (addCount.get() == 0 && System.currentTimeMillis() < deadline) {
+ Thread.sleep(20);
+ }
+ assertThat(addCount.get()).isEqualTo(1);
+ }
+
+ @Test
+ void syncTurn_skipsWhenBothMessagesBlank() throws Exception {
+ provider.syncTurn(1L, "conv-1", " ", "");
+ Thread.sleep(200); // give async a chance to (not) fire
+ assertThat(addCount.get()).isZero();
+ }
+
+ @Test
+ void syncTurn_failureIsSwallowedAndDoesNotThrow() throws Exception {
+ // Stop the server so the async POST fails; provider must not propagate.
+ server.stop(0);
+ // Re-create a stub server just so tearDown doesn't NPE; not listening
+ // on the original port anymore — the client will get connection refused.
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", ex -> { ex.sendResponseHeaders(200, 0); ex.close(); });
+ // Note: client still points at the old port → connection refused.
+
+ provider.syncTurn(1L, "conv-1", "hi", "there");
+ Thread.sleep(500);
+ // No exception thrown; nothing to assert beyond "test didn't blow up".
+ }
+
+ @Test
+ void syncTurn_skippedWhenSyncDisabled() throws Exception {
+ // Build a provider with sync disabled.
+ Mem0Config cfg = new Mem0Config(
+ "http://127.0.0.1:" + server.getAddress().getPort(),
+ null, true, false, 3, 3000);
+ Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
+ p.syncTurn(1L, "conv-1", "hi", "there");
+ Thread.sleep(200);
+ assertThat(addCount.get()).isZero();
+ }
+
+ @Test
+ void prefetch_skippedWhenSearchDisabled() {
+ Mem0Config cfg = new Mem0Config(
+ "http://127.0.0.1:" + server.getAddress().getPort(),
+ null, false, true, 3, 3000);
+ Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
+ assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
+ assertThat(searchCount.get()).isZero();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java
index 23ce8c45..f8521cfa 100644
--- a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java
@@ -45,6 +45,12 @@ public class PluginMemoryBridge implements MemoryProvider {
return delegate.prefetch(agentId, userQuery);
}
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ // 透传 ownerKey 到插件 provider;插件若未 override 三参版会走 default 退化到两参
+ return delegate.prefetch(agentId, userQuery, ownerKey);
+ }
+
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply) {
diff --git a/mateclaw-server/src/main/resources/docs/en/memory.md b/mateclaw-server/src/main/resources/docs/en/memory.md
index 51b49544..5eb1d9c9 100644
--- a/mateclaw-server/src/main/resources/docs/en/memory.md
+++ b/mateclaw-server/src/main/resources/docs/en/memory.md
@@ -569,6 +569,64 @@ For developers extending the memory layer, see [Architecture](./architecture).
---
+## Mem0 Integration (Optional)
+
+::: warning Not in the default stack
+Mem0 integration is an **optional community contribution** — it is NOT part of a default MateClaw install. It requires you to **self-host a Mem0 service** (FastAPI + pgvector + optional Neo4j). MateClaw's "local-first, zero external dependencies" stance is unchanged — this plugin just adds an **additive semantic recall channel** for people willing to run that extra service.
+:::
+
+[Mem0](https://github.com/mem0ai/mem0) is a standalone memory service that handles LLM memory extraction, deduplication, and vector-based recall. MateClaw's `mateclaw-plugin-mem0` module plugs it in as a **plugin-style memory provider** — none of the 4 built-in providers (Builtin / Structured / Session / Fact) are touched. Mem0 stacks on top as a 5th, external provider. **They don't replace each other.**
+
+### What it does
+
+| Hook | Behavior |
+|------|----------|
+| `systemPromptBlock` | Returns empty — leaves the resident system prompt alone, avoids per-turn token bloat |
+| `prefetch(agentId, query, ownerKey)` | When `searchEnabled=true` and `ownerKey` is non-blank, calls `POST {baseUrl}/memories/search/` and returns a `[Mem0 Recall]` block concatenated into the current turn's context |
+| `syncTurn(agentId, conversationId, userMessage, assistantReply)` | When `syncEnabled=true`, **asynchronously** pushes this turn's user/assistant messages to `POST {baseUrl}/memories/`. Failures are logged only, never block the response |
+| `getToolBeans` | Empty list — v1 exposes no agent-callable tools |
+
+**Fault isolation**: any exception in recall or sync is swallowed and logged by the plugin itself; the platform keeps going with the other providers. Mem0 being down does not affect MateClaw's local memory.
+
+### Per-owner isolation mapping
+
+Mem0 isolates by `user_id` + `agent_id`. MateClaw maps them as:
+
+| MateClaw field | Mem0 field | Notes |
+|---|---|---|
+| `ownerKey` (e.g. `user:42` / `feishu:sender_abc`) | `user_id` | Passed through verbatim |
+| `agentId` | `agent_id` | The digital employee ID |
+
+Only the three-arg `prefetch` variant receives `ownerKey`. The two-arg variant (no ownerKey) returns empty — Mem0 requires `user_id`, without it isolation is impossible.
+
+### Installation
+
+1. **Deploy Mem0**: following Mem0's official docs, self-host an instance (FastAPI + pgvector + optional Neo4j). Note its base URL, e.g. `http://localhost:8080`.
+2. **Build the plugin JAR**: from the MateClaw repo root, run `mvn -pl mateclaw-plugin-mem0 -am package` — the JAR lands at `mateclaw-plugin-mem0/target/mateclaw-plugin-mem0-*.jar`.
+3. **Drop the JAR**: place it in MateClaw's `plugins/` directory.
+4. **Configure**: in the plugin admin UI, set `baseUrl` (required) and optionally `apiKey` and other tunables. Restart or reload the plugin.
+
+### Configuration
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `baseUrl` | string | yes | — | Mem0 REST API base URL, e.g. `http://localhost:8080` |
+| `apiKey` | string | no | — | Bearer token sent as the `Authorization` header to Mem0 |
+| `searchEnabled` | boolean | no | `true` | Whether prefetch should call `/memories/search/` for semantic recall |
+| `syncEnabled` | boolean | no | `true` | Whether syncTurn should push each turn to `/memories/` |
+| `maxResults` | integer | no | `5` | Cap on memories returned per recall |
+| `timeoutMs` | integer | no | `3000` | HTTP timeout in milliseconds, shared by recall and sync |
+
+Config is read once at plugin load — changes require a plugin reload to take effect.
+
+### Known limitations (v1)
+
+- **`syncTurn` has no `ownerKey`**: the plugin SPI's `syncTurn` signature is only `(agentId, conversationId, userMessage, assistantReply)`, so when pushing to Mem0 the plugin falls back to using `agentId` as `user_id`. This is coarser isolation than prefetch (which has ownerKey). If you need strict per-owner sync, set `syncEnabled=false` and rely on prefetch-only recall, with writes handled by your own Mem0 client.
+- **No token budget control**: the `[Mem0 Recall]` block returned by prefetch is concatenated into the context directly — it is NOT subject to the `system-block-max-chars` injection budget (that budget only governs `user`/`feedback` structured entries). `maxResults` is the only size knob.
+- **No agent tools**: v1 does not expose `mem0_search` / `mem0_add` style tools for the agent to call proactively. The agent only passively receives prefetch results.
+
+---
+
## Next
- [Agents](./agents) — how agents use memory during a turn
diff --git a/mateclaw-server/src/main/resources/docs/zh/memory.md b/mateclaw-server/src/main/resources/docs/zh/memory.md
index 4c973c78..d457c185 100644
--- a/mateclaw-server/src/main/resources/docs/zh/memory.md
+++ b/mateclaw-server/src/main/resources/docs/zh/memory.md
@@ -563,6 +563,64 @@ mate:
---
+## Mem0 集成(可选)
+
+::: warning 非默认栈
+Mem0 集成是**可选的社区贡献项**,不在 MateClaw 的默认安装里。它需要你**自己部署一份 Mem0 服务**(FastAPI + pgvector + 可选 Neo4j)。MateClaw 的"本地优先、零外部依赖"定位不变——这个插件只是给愿意多跑一套服务的人一个**叠加的语义召回通道**。
+:::
+
+[Mem0](https://github.com/mem0ai/mem0) 是一个独立的记忆服务,做的是 LLM 记忆的提取、去重、向量化召回。MateClaw 的 `mateclaw-plugin-mem0` 模块把它作为一个**插件式 memory provider** 接进来——内部 4 个 provider(Builtin / Structured / Session / Fact)一个都不动,Mem0 作为第 5 个外部 provider 叠加上去,**互不替代**。
+
+### 它做什么
+
+| 钩子 | 行为 |
+|------|------|
+| `systemPromptBlock` | 返回空——常驻 system prompt 不动,避免每轮 token 膨胀 |
+| `prefetch(agentId, query, ownerKey)` | 当 `searchEnabled=true` 且 `ownerKey` 非空时,调 `POST {baseUrl}/memories/search/`,返回一个 `[Mem0 Recall]` 块拼进本轮上下文 |
+| `syncTurn(agentId, conversationId, userMessage, assistantReply)` | 当 `syncEnabled=true` 时,**异步**把这一轮的 user/assistant 消息推到 `POST {baseUrl}/memories/`,失败只记日志、不阻塞响应 |
+| `getToolBeans` | 空列表——v1 不暴露 Agent 可调用的工具 |
+
+**故障隔离**:recall 或 sync 任何一边抛异常,插件自己吞掉、写日志,平台继续走其他 provider。Mem0 挂了不会影响 MateClaw 的本地记忆。
+
+### per-owner 隔离的映射
+
+Mem0 用 `user_id` + `agent_id` 做隔离。MateClaw 的映射:
+
+| MateClaw 字段 | Mem0 字段 | 说明 |
+|---|---|---|
+| `ownerKey`(如 `user:42` / `feishu:sender_abc`) | `user_id` | 透传,原样作为 user_id |
+| `agentId` | `agent_id` | 数字员工 ID |
+
+只有 `prefetch` 的三参版能拿到 `ownerKey`。两参版(无 ownerKey)会直接返回空——Mem0 要求 `user_id`,没它无法隔离。
+
+### 安装步骤
+
+1. **部署 Mem0 服务**:参考 Mem0 官方文档,自托管一份(FastAPI + pgvector + 可选 Neo4j)。记下它的 base URL,比如 `http://localhost:8080`。
+2. **构建插件 JAR**:在 MateClaw 仓库根目录跑 `mvn -pl mateclaw-plugin-mem0 -am package`,得到 `mateclaw-plugin-mem0/target/mateclaw-plugin-mem0-*.jar`。
+3. **放 JAR**:把 JAR 丢进 MateClaw 的 `plugins/` 目录。
+4. **配置**:在插件管理 UI 里填 `baseUrl`(必填),按需填 `apiKey`、调其他参数。重启或重载插件。
+
+### 配置项
+
+| 字段 | 类型 | 必填 | 默认 | 说明 |
+|------|------|------|------|------|
+| `baseUrl` | string | 是 | — | Mem0 REST API 地址,如 `http://localhost:8080` |
+| `apiKey` | string | 否 | — | Bearer token,作为 `Authorization` 头发给 Mem0 |
+| `searchEnabled` | boolean | 否 | `true` | 是否在 prefetch 时调 `/memories/search/` 做语义召回 |
+| `syncEnabled` | boolean | 否 | `true` | 是否在 syncTurn 时把每轮对话推到 `/memories/` |
+| `maxResults` | integer | 否 | `5` | 每次召回返回的记忆条数上限 |
+| `timeoutMs` | integer | 否 | `3000` | HTTP 超时(毫秒),recall 和 sync 共用 |
+
+配置只在插件加载时读一次——改了要重载插件才会生效。
+
+### 已知限制(v1)
+
+- **`syncTurn` 拿不到 `ownerKey`**:插件 SPI 的 `syncTurn` 签名只有 `(agentId, conversationId, userMessage, assistantReply)`,所以推送 Mem0 时只能用 `agentId` 作为 `user_id` 降级。这比 prefetch(有 ownerKey)的隔离粒度粗。如果你需要严格的 per-owner 同步,把 `syncEnabled=false`,只依赖 prefetch 做召回,由你自己的 Mem0 客户端负责写入。
+- **没有 token 预算控制**:prefetch 返回的 `[Mem0 Recall]` 块直接拼进上下文,不受 `system-block-max-chars` 那套注入预算约束(那套只管 `user`/`feedback` 结构化条目)。`maxResults` 是唯一的尺寸闸门。
+- **没有 Agent 工具**:v1 不暴露 `mem0_search` / `mem0_add` 之类的工具给 Agent 主动调用。Agent 只能被动接收 prefetch 的结果。
+
+---
+
## 下一步
- [Agent 引擎](./agents)——Agent 在一个回合里怎么用记忆
diff --git a/mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerPluginPrefetchTest.java b/mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerPluginPrefetchTest.java
new file mode 100644
index 00000000..d9eaecbb
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerPluginPrefetchTest.java
@@ -0,0 +1,223 @@
+package vip.mate.memory;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.ObjectProvider;
+import vip.mate.memory.spi.MemoryManager;
+import vip.mate.memory.spi.MemoryProvider;
+import vip.mate.plugin.api.memory.PluginMemoryProvider;
+import vip.mate.plugin.bridge.PluginMemoryBridge;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test for the end-to-end prefetch path:
+ *
This is the test that catches the silent regression where the bridge
+ * forgets to override the three-arg variant and the plugin's per-owner
+ * isolation is lost. The L2 {@code PluginMemoryBridgeTest} covers the
+ * bridge in isolation; this test verifies the platform actually invokes
+ * the three-arg path when an owner key is present.
+ *
+ *
Style follows {@link MemoryManagerBudgetTest}: construct the manager
+ * directly with stub providers, no Spring context.
+ */
+class MemoryManagerPluginPrefetchTest {
+
+ @Test
+ @DisplayName("prefetchAll with ownerKey reaches the plugin's three-arg prefetch verbatim")
+ void prefetchAllForwardsOwnerKeyToPlugin() {
+ AtomicReference receivedOwner = new AtomicReference<>("sentinel");
+ AtomicReference receivedAgent = new AtomicReference<>();
+ AtomicReference receivedQuery = new AtomicReference<>();
+
+ PluginMemoryProvider plugin = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "test-plugin-mem"; }
+
+ @Override
+ public boolean isAvailable() { return true; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ receivedOwner.set(ownerKey);
+ receivedAgent.set(agentId);
+ receivedQuery.set(userQuery);
+ return "[Plugin Recall] user has Scala + Cats Effect stack";
+ }
+ };
+
+ MemoryProvider bridge = new PluginMemoryBridge(plugin);
+ MemoryManager manager = newManager(bridge);
+
+ String result = manager.prefetchAll(7L, "what stack does my project use", "user:42");
+
+ assertEquals("user:42", receivedOwner.get(),
+ "ownerKey must reach the plugin — this is the whole point of the SPI extension");
+ assertEquals(7L, receivedAgent.get());
+ assertEquals("what stack does my project use", receivedQuery.get());
+ assertTrue(result.contains("[Plugin Recall]"),
+ "plugin's recall block must appear in the merged context: " + result);
+ }
+
+ @Test
+ @DisplayName("prefetchAll(agentId, query) without ownerKey delegates to the three-arg path " +
+ "with null ownerKey — plugins see null and can opt out of per-owner recall")
+ void prefetchAllWithoutOwnerKeyPassesNullToThreeArg() {
+ AtomicReference receivedOwner = new AtomicReference<>("sentinel");
+ PluginMemoryProvider plugin = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "test-plugin-mem"; }
+
+ @Override
+ public boolean isAvailable() { return true; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ receivedOwner.set(ownerKey);
+ return ownerKey == null ? "" : "[should not happen]";
+ }
+ };
+
+ MemoryManager manager = newManager(new PluginMemoryBridge(plugin));
+ String result = manager.prefetchAll(7L, "hello");
+
+ assertEquals(null, receivedOwner.get(),
+ "two-arg prefetchAll must surface as null ownerKey to the plugin's " +
+ "three-arg variant — null is the contract signal that lets plugins " +
+ "skip per-owner work without a separate code path");
+ assertFalse(result.contains("[should not happen]"));
+ }
+
+ @Test
+ @DisplayName("multiple providers — plugin's block joins the others, ownerKey still reaches it")
+ void multipleProvidersJoinAndOwnerKeyReachesPlugin() {
+ AtomicReference pluginOwner = new AtomicReference<>();
+ // A builtin-style provider that ignores ownerKey (two-arg semantics).
+ MemoryProvider builtin = new MemoryProvider() {
+ @Override
+ public String id() { return "builtin"; }
+
+ @Override
+ public boolean isAvailable() { return true; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ return "[Builtin] recent context";
+ }
+ };
+ PluginMemoryProvider plugin = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "test-plugin-mem"; }
+
+ @Override
+ public boolean isAvailable() { return true; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ pluginOwner.set(ownerKey);
+ return "[Plugin Recall] semantic match";
+ }
+ };
+
+ MemoryManager manager = newManager(builtin, new PluginMemoryBridge(plugin));
+ String result = manager.prefetchAll(1L, "query", "user:99");
+
+ assertEquals("user:99", pluginOwner.get());
+ assertTrue(result.contains("[Builtin]"), "builtin block present: " + result);
+ assertTrue(result.contains("[Plugin Recall]"), "plugin block present: " + result);
+ }
+
+ @Test
+ @DisplayName("plugin prefetch exception is swallowed — fault isolation holds at the manager level")
+ void pluginExceptionIsSwallowed() {
+ PluginMemoryProvider plugin = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "test-plugin-mem"; }
+
+ @Override
+ public boolean isAvailable() { return true; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ throw new IllegalStateException("simulated Mem0 outage");
+ }
+ };
+ MemoryProvider builtin = stubBuiltin();
+ MemoryManager manager = newManager(builtin, new PluginMemoryBridge(plugin));
+
+ // Must NOT throw — Mem0 being down must not break the platform.
+ String result = manager.prefetchAll(1L, "query", "user:42");
+ assertTrue(result.contains("[Builtin]"),
+ "builtin provider must still contribute even if the plugin threw: " + result);
+ }
+
+ @Test
+ @DisplayName("an unavailable plugin is filtered out at construction (isAvailable()=false)")
+ void unavailablePluginIsFiltered() {
+ List called = new ArrayList<>();
+ PluginMemoryProvider plugin = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "test-plugin-mem"; }
+
+ @Override
+ public boolean isAvailable() { return false; } // configured but not usable
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ called.add("should-not-reach");
+ return "[Plugin Recall]";
+ }
+ };
+ MemoryManager manager = newManager(new PluginMemoryBridge(plugin));
+
+ String result = manager.prefetchAll(1L, "query", "user:42");
+ assertTrue(called.isEmpty(),
+ "an unavailable plugin must not be called — manager filters at construction");
+ assertFalse(result.contains("[Plugin Recall]"));
+ }
+
+ // ---- helpers ----
+
+ private static MemoryProvider stubBuiltin() {
+ return new MemoryProvider() {
+ @Override
+ public String id() { return "builtin"; }
+
+ @Override
+ public boolean isAvailable() { return true; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ return "[Builtin] recent context";
+ }
+ };
+ }
+
+ private static MemoryManager newManager(MemoryProvider... providers) {
+ ObjectProvider noRegistry = new ObjectProvider<>() {
+ @Override
+ public MeterRegistry getObject(Object... args) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public MeterRegistry getIfAvailable() {
+ return null;
+ }
+ };
+ return new MemoryManager(List.of(providers), new MemoryProperties(), noRegistry);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginMemoryBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginMemoryBridgeTest.java
new file mode 100644
index 00000000..30789253
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginMemoryBridgeTest.java
@@ -0,0 +1,207 @@
+package vip.mate.plugin.bridge;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import vip.mate.memory.spi.MemoryProvider;
+import vip.mate.plugin.api.memory.PluginMemoryProvider;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link PluginMemoryBridge} adapts the self-contained plugin SPI
+ * ({@link PluginMemoryProvider}) to the platform's internal
+ * {@link MemoryProvider} interface.
+ *
+ *
These tests focus on the contract added for per-owner isolation:
+ * the three-arg {@code prefetch(agentId, query, ownerKey)} must forward
+ * {@code ownerKey} verbatim to the delegate. If a future refactor reverts
+ * the bridge to the two-arg default, per-owner recall silently breaks for
+ * every plugin provider — this test guards against that regression.
+ */
+class PluginMemoryBridgeTest {
+
+ @Test
+ @DisplayName("three-arg prefetch forwards ownerKey verbatim to the delegate")
+ void threeArgPrefetchForwardsOwnerKey() {
+ AtomicReference receivedOwner = new AtomicReference<>();
+ AtomicReference receivedQuery = new AtomicReference<>();
+ AtomicReference receivedAgent = new AtomicReference<>();
+ PluginMemoryProvider delegate = stub();
+ delegate = new ForwardingPluginProvider(delegate) {
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ receivedOwner.set(ownerKey);
+ receivedQuery.set(userQuery);
+ receivedAgent.set(agentId);
+ return "[plugin recall]";
+ }
+ };
+
+ PluginMemoryBridge bridge = new PluginMemoryBridge(delegate);
+ String result = bridge.prefetch(42L, "hello", "user:42");
+
+ assertEquals("user:42", receivedOwner.get(),
+ "ownerKey must reach the delegate — per-owner isolation depends on it");
+ assertEquals("hello", receivedQuery.get());
+ assertEquals(42L, receivedAgent.get());
+ assertEquals("[plugin recall]", result);
+ }
+
+ @Test
+ @DisplayName("two-arg prefetch on the bridge forwards to the plugin's two-arg variant " +
+ "(ownerKey is genuinely absent from this call path)")
+ void twoArgPrefetchForwardsToTwoArgVariant() {
+ AtomicReference twoArgCalled = new AtomicReference<>("not-called");
+ AtomicReference threeArgCalled = new AtomicReference<>("not-called");
+ PluginMemoryProvider delegate = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "two-arg-tracker"; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery) {
+ twoArgCalled.set("called");
+ return "[two-arg result]";
+ }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery, String ownerKey) {
+ threeArgCalled.set("called-with-" + ownerKey);
+ return "[three-arg result]";
+ }
+ };
+
+ PluginMemoryBridge bridge = new PluginMemoryBridge(delegate);
+ String result = bridge.prefetch(42L, "hello");
+
+ assertEquals("called", twoArgCalled.get(),
+ "bridge two-arg prefetch must forward to the plugin's two-arg variant");
+ assertEquals("not-called", threeArgCalled.get(),
+ "bridge two-arg prefetch must NOT invoke the plugin's three-arg variant — " +
+ "ownerKey is absent from this call path");
+ assertEquals("[two-arg result]", result);
+ }
+
+ @Test
+ @DisplayName("when the plugin does not override the three-arg variant, " +
+ "the SPI default degrades to two-arg (ownerKey dropped, not crashed)")
+ void threeArgPrefetchDegradesToTwoArgWhenPluginDoesNotOverride() {
+ // A plugin that only implements the two-arg prefetch.
+ PluginMemoryProvider pluginOnlyTwoArg = new PluginMemoryProvider() {
+ @Override
+ public String id() { return "two-arg-only"; }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery) {
+ return "[two-arg recall for " + agentId + "]";
+ }
+ };
+
+ PluginMemoryBridge bridge = new PluginMemoryBridge(pluginOnlyTwoArg);
+ // Three-arg call must degrade gracefully via the SPI default method.
+ String result = bridge.prefetch(42L, "hello", "user:42");
+ assertEquals("[two-arg recall for 42]", result,
+ "SPI default should drop ownerKey and route to the two-arg impl, " +
+ "not crash with AbstractMethodError");
+ }
+
+ @Test
+ @DisplayName("metadata (id/order/isAvailable) and lifecycle hooks pass through")
+ void metadataAndLifecyclePassthrough() {
+ PluginMemoryProvider delegate = stub();
+ PluginMemoryBridge bridge = new PluginMemoryBridge(delegate);
+
+ assertEquals("stub-mem", bridge.id());
+ assertEquals(200, bridge.order());
+ assertTrue(bridge.isAvailable());
+
+ // syncTurn / onSessionEnd / systemPromptBlock are pure forwarders; calling
+ // them must not throw.
+ bridge.syncTurn(1L, "conv-1", "hi", "hello");
+ bridge.onSessionEnd(1L, "conv-1");
+ assertEquals("", bridge.systemPromptBlock(1L));
+ }
+
+ @Test
+ @DisplayName("a null getToolBeans() from a sloppy plugin is normalised to emptyList")
+ void nullToolBeansNormalised() {
+ PluginMemoryProvider sloppy = new ForwardingPluginProvider(stub()) {
+ @Override
+ public List