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}: + *

+ * + *

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: + *

    + *
  1. {@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.
  2. + *
  3. {@code onEnable} / {@code onDisable} — lifecycle log only.
  4. + *
+ * + *

This plugin is NOT part of the default stack. Users must: + *

    + *
  1. Self-host a Mem0 service (FastAPI + pgvector + optional Neo4j)
  2. + *
  3. Drop the built JAR into the platform's {@code plugins/} directory
  4. + *
  5. Configure {@code baseUrl} (and optionally {@code apiKey}) via the + * plugin admin UI
  6. + *
+ * + * @author MateClaw Team + */ +public class Mem0Plugin implements MateClawPlugin { + + private static final String CONFIG_BASE_URL = "baseUrl"; + private static final String CONFIG_API_KEY = "apiKey"; + private static final String CONFIG_SEARCH_ENABLED = "searchEnabled"; + private static final String CONFIG_SYNC_ENABLED = "syncEnabled"; + private static final String CONFIG_MAX_RESULTS = "maxResults"; + private static final String CONFIG_TIMEOUT_MS = "timeoutMs"; + + private Logger log; + + @Override + public void onLoad(PluginContext context) { + this.log = context.getLogger(); + + Mem0Config config = readConfig(context); + if (!config.isUsable()) { + log.warn("Mem0 plugin loaded without baseUrl — provider will stay unavailable. " + + "Configure 'baseUrl' in the plugin config to enable."); + } + + Mem0Client client = new Mem0Client(config); + Mem0Provider provider = new Mem0Provider(config, client, log); + context.registerMemoryProvider(provider); + + log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}", + maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(), + config.maxResults(), config.timeoutMs()); + } + + @Override + public void onEnable() { + if (log != null) log.info("Mem0 plugin enabled"); + } + + @Override + public void onDisable() { + if (log != null) log.info("Mem0 plugin disabled"); + } + + private Mem0Config readConfig(PluginContext ctx) { + String baseUrl = ctx.getConfig(CONFIG_BASE_URL, String.class); + String apiKey = ctx.getConfig(CONFIG_API_KEY, String.class); + Boolean searchEnabled = ctx.getConfig(CONFIG_SEARCH_ENABLED, Boolean.class); + Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class); + Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class); + Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class); + + return new Mem0Config( + baseUrl, + apiKey, + searchEnabled == null ? true : searchEnabled, + syncEnabled == null ? true : syncEnabled, + maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults, + timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs + ); + } + + /** + * Mask credentials in the URL when logging. Keeps the scheme + host, + * strips any user info and path. + */ + private static String maskUrl(String url) { + if (url == null || url.isBlank()) return "(unset)"; + try { + java.net.URI u = java.net.URI.create(url); + String host = u.getHost(); + int port = u.getPort(); + return u.getScheme() + "://" + host + (port > 0 ? ":" + port : ""); + } catch (Exception e) { + return "(malformed)"; + } + } +} diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java new file mode 100644 index 00000000..f65a99ed --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java @@ -0,0 +1,171 @@ +package vip.mate.plugin.mem0; + +import org.slf4j.Logger; +import vip.mate.plugin.api.memory.PluginMemoryProvider; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted + * Mem0 service. + *

+ * Behavior matrix: + *

+ * + *

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: + *

+ *   MemoryManager.prefetchAll(agentId, query, ownerKey)
+ *     → MemoryProvider (which is a PluginMemoryBridge)
+ *       → PluginMemoryProvider.prefetch(agentId, query, ownerKey)  [three-arg variant]
+ * 
+ * + *

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 getToolBeans() { + return null; // sloppy plugin returns null + } + }; + PluginMemoryBridge bridge = new PluginMemoryBridge(sloppy); + + List tools = bridge.getToolBeans(); + assertNotNull(tools, "bridge must never expose null to the platform"); + assertTrue(tools.isEmpty()); + } + + @Test + @DisplayName("a non-empty getToolBeans() list is forwarded as-is") + void nonEmptyToolBeansForwarded() { + Object toolBean = new Object(); + PluginMemoryProvider delegate = new ForwardingPluginProvider(stub()) { + @Override + public List getToolBeans() { + return List.of(toolBean); + } + }; + PluginMemoryBridge bridge = new PluginMemoryBridge(delegate); + + List tools = bridge.getToolBeans(); + assertEquals(1, tools.size()); + assertSame(toolBean, tools.get(0)); + } + + // ---- helpers ---- + + private static PluginMemoryProvider stub() { + return new PluginMemoryProvider() { + @Override + public String id() { return "stub-mem"; } + // other methods keep their default implementations + }; + } + + /** + * Base class that forwards every method to a delegate, so individual tests + * only override the one method they care about. Mirrors the pattern in + * {@code PluginSearchBridgeTest}'s anonymous stubs. + */ + private static class ForwardingPluginProvider implements PluginMemoryProvider { + private final PluginMemoryProvider delegate; + + ForwardingPluginProvider(PluginMemoryProvider delegate) { + this.delegate = delegate; + } + + @Override public String id() { return delegate.id(); } + @Override public int order() { return delegate.order(); } + @Override public boolean isAvailable() { return delegate.isAvailable(); } + @Override public String systemPromptBlock(Long agentId) { + return delegate.systemPromptBlock(agentId); + } + @Override public String prefetch(Long agentId, String userQuery) { + return delegate.prefetch(agentId, userQuery); + } + @Override public String prefetch(Long agentId, String userQuery, String ownerKey) { + return delegate.prefetch(agentId, userQuery, ownerKey); + } + @Override public void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply) { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + } + @Override public List getToolBeans() { return delegate.getToolBeans(); } + @Override public void onSessionEnd(Long agentId, String conversationId) { + delegate.onSessionEnd(agentId, conversationId); + } + } +} diff --git a/pom.xml b/pom.xml index af6743c8..d13f2798 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ mateclaw-server mateclaw-plugin-sample mateclaw-plugin-search-sample + mateclaw-plugin-mem0