feat(memory): add Mem0 as an optional external plugin memory provider

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.
This commit is contained in:
Lcos 2026-07-26 10:52:01 +08:00 committed by GitHub
parent 7fb23e5404
commit 396cdb175b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1755 additions and 0 deletions

View File

@ -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.
* <p>
* 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).

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-mem0</artifactId>
<packaging>jar</packaging>
<name>MateClaw Mem0 Memory Provider Plugin</name>
<description>
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.
</description>
<dependencies>
<!-- MateClaw Plugin API -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Spring AI (provided by the platform) — PluginContext method signatures
reference ToolCallback/ChatModel, so it must be resolvable at compile time -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<scope>provided</scope>
</dependency>
<!-- Jackson (provided by the platform parent classloader) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
<!-- SLF4J (provided by the platform) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Test only -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- slf4j-simple: gives the plugin a real logger during tests so
LoggerFactory.getLogger doesn't fall back to NOP silently -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -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.
* <p>
* Covers the two endpoints used by {@link Mem0Provider}:
* <ul>
* <li>{@code POST /memories/} add a turn (user + assistant message) for extraction</li>
* <li>{@code POST /memories/search/} semantic recall by query + user_id</li>
* </ul>
*
* <p>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<String> 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<String> 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<String> 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<String, Object> 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);
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.plugin.mem0;
/**
* Mem0 plugin configuration snapshot.
* <p>
* 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;
}
}

View File

@ -0,0 +1,21 @@
package vip.mate.plugin.mem0;
/**
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
* <p>
* 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);
}
}

View File

@ -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.
* <p>
* Lifecycle:
* <ol>
* <li>{@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.</li>
* <li>{@code onEnable} / {@code onDisable} lifecycle log only.</li>
* </ol>
*
* <p>This plugin is NOT part of the default stack. Users must:
* <ol>
* <li>Self-host a Mem0 service (FastAPI + pgvector + optional Neo4j)</li>
* <li>Drop the built JAR into the platform's {@code plugins/} directory</li>
* <li>Configure {@code baseUrl} (and optionally {@code apiKey}) via the
* plugin admin UI</li>
* </ol>
*
* @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)";
}
}
}

View File

@ -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.
* <p>
* Behavior matrix:
* <ul>
* <li>{@code systemPromptBlock} no-op (returns ""), aligns with SessionSearchProvider</li>
* <li>{@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.</li>
* <li>{@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.</li>
* <li>{@code getToolBeans} empty (no agent-facing tools in v1)</li>
* </ul>
*
* <p>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}.
*
* <p>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<String> 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.
* <p>
* 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<String> 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();
}
}

View File

@ -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."
}
}
}

View File

@ -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<String> lastPath = new AtomicReference<>();
private final AtomicReference<String> lastBody = new AtomicReference<>();
private final AtomicReference<String> 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<String> 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<String, Object> 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");
}
}

View File

@ -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");
}
}

View File

@ -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<String, Object> 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<PluginMemoryProvider> 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<String, Object> config = new HashMap<>(); // empty
AtomicReference<PluginMemoryProvider> 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<String, Object> config = new HashMap<>();
config.put("baseUrl", "http://localhost:8080");
AtomicReference<PluginMemoryProvider> 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<String, Object> config = new HashMap<>();
config.put("baseUrl", "http://localhost:8080");
AtomicReference<PluginMemoryProvider> 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<String, Object> config;
private final AtomicReference<PluginMemoryProvider> registered;
StubContext(Map<String, Object> config, AtomicReference<PluginMemoryProvider> registered) {
this.config = config;
this.registered = registered;
}
@Override
@SuppressWarnings("unchecked")
public <T> T getConfig(String key, Class<T> 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<Boolean> 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(); }
}
}

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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 个 providerBuiltin / 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 在一个回合里怎么用记忆

View File

@ -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:
* <pre>
* MemoryManager.prefetchAll(agentId, query, ownerKey)
* MemoryProvider (which is a PluginMemoryBridge)
* PluginMemoryProvider.prefetch(agentId, query, ownerKey) [three-arg variant]
* </pre>
*
* <p>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.
*
* <p>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<String> receivedOwner = new AtomicReference<>("sentinel");
AtomicReference<Long> receivedAgent = new AtomicReference<>();
AtomicReference<String> 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<String> 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<String> 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<String> 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<MeterRegistry> 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);
}
}

View File

@ -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.
*
* <p>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<String> receivedOwner = new AtomicReference<>();
AtomicReference<String> receivedQuery = new AtomicReference<>();
AtomicReference<Long> 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<String> twoArgCalled = new AtomicReference<>("not-called");
AtomicReference<String> 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<Object> getToolBeans() {
return null; // sloppy plugin returns null
}
};
PluginMemoryBridge bridge = new PluginMemoryBridge(sloppy);
List<Object> 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<Object> getToolBeans() {
return List.of(toolBean);
}
};
PluginMemoryBridge bridge = new PluginMemoryBridge(delegate);
List<Object> 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<Object> getToolBeans() { return delegate.getToolBeans(); }
@Override public void onSessionEnd(Long agentId, String conversationId) {
delegate.onSessionEnd(agentId, conversationId);
}
}
}

View File

@ -18,6 +18,7 @@
<module>mateclaw-server</module>
<module>mateclaw-plugin-sample</module>
<module>mateclaw-plugin-search-sample</module>
<module>mateclaw-plugin-mem0</module>
</modules>
<properties>