diff --git a/data/mateclaw.mv.db b/data/mateclaw.mv.db
new file mode 100644
index 00000000..3dead6b6
Binary files /dev/null and b/data/mateclaw.mv.db differ
diff --git a/mateclaw-plugin-api/pom.xml b/mateclaw-plugin-api/pom.xml
new file mode 100644
index 00000000..0221ea40
--- /dev/null
+++ b/mateclaw-plugin-api/pom.xml
@@ -0,0 +1,67 @@
+
+
+ 4.0.0
+
+ vip.mate
+ mateclaw-plugin-api
+ 1.1.0-SNAPSHOT
+ jar
+
+ MateClaw Plugin API
+ Plugin SDK contract for MateClaw — external plugins depend only on this module
+
+
+ 21
+ 21
+ 21
+ UTF-8
+ 1.1.4
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-model
+ provided
+
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.16
+ provided
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.18.3
+ provided
+
+
+
+
+
+ spring-milestones
+ https://repo.spring.io/milestone
+ false
+
+
+
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java
new file mode 100644
index 00000000..1801dfb2
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java
@@ -0,0 +1,34 @@
+package vip.mate.plugin.api;
+
+/**
+ * Plugin SPI contract — all MateClaw plugins implement this interface.
+ *
+ *
Lifecycle:
+ *
+ * {@link #onLoad(PluginContext)} — called when the plugin is loaded, platform context injected
+ * {@link #onEnable()} — called when the plugin is enabled, register features to the platform
+ * {@link #onDisable()} — called when the plugin is disabled, clean up resources
+ *
+ *
+ * @author MateClaw Team
+ */
+public interface MateClawPlugin {
+
+ /**
+ * Called when the plugin is loaded. Use the provided context to register
+ * tools, providers, channels, or memory providers.
+ *
+ * @param context platform context providing registration APIs
+ */
+ void onLoad(PluginContext context);
+
+ /**
+ * Called when the plugin is enabled. Perform any startup logic here.
+ */
+ void onEnable();
+
+ /**
+ * Called when the plugin is disabled. Clean up resources, close connections, etc.
+ */
+ void onDisable();
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java
new file mode 100644
index 00000000..5eb50c5e
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java
@@ -0,0 +1,79 @@
+package vip.mate.plugin.api;
+
+import org.slf4j.Logger;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.tool.ToolCallback;
+import vip.mate.plugin.api.channel.PluginChannelAdapter;
+import vip.mate.plugin.api.memory.PluginMemoryProvider;
+
+import java.util.function.Supplier;
+
+/**
+ * Platform API provided to plugins for registering capabilities.
+ *
+ * @author MateClaw Team
+ */
+public interface PluginContext {
+
+ /**
+ * Register a tool that will be available to agents.
+ *
+ * @param tool the tool callback
+ */
+ void registerTool(ToolCallback tool);
+
+ /**
+ * Register a tool with an availability check.
+ *
+ * The check function is evaluated lazily each time the agent tool set is built.
+ * When it returns {@code false}, the tool is silently excluded from the agent's
+ * available tools — useful for tools that require an external API key or dependency.
+ *
+ * @param tool the tool callback
+ * @param availabilityCheck returns true if the tool should be available
+ */
+ void registerTool(ToolCallback tool, Supplier availabilityCheck);
+
+ /**
+ * Register a custom LLM provider.
+ *
+ * @param providerId unique provider identifier
+ * @param chatModel the chat model implementation
+ */
+ void registerProvider(String providerId, ChatModel chatModel);
+
+ /**
+ * Register a messaging channel adapter.
+ *
+ * @param channel the channel adapter
+ */
+ void registerChannel(PluginChannelAdapter channel);
+
+ /**
+ * Register a memory provider.
+ *
+ * Only one external memory provider is allowed at a time.
+ * If another plugin has already registered one, a {@link PluginException} is thrown.
+ *
+ * @param provider the memory provider
+ * @throws PluginException if an external memory provider is already registered
+ */
+ void registerMemoryProvider(PluginMemoryProvider provider);
+
+ /**
+ * Read a configuration value from the plugin's config.
+ *
+ * @param key the config key
+ * @param type the expected type
+ * @param the type
+ * @return the config value, or null if not set
+ */
+ T getConfig(String key, Class type);
+
+ /**
+ * Get a logger instance for this plugin.
+ *
+ * @return the logger
+ */
+ Logger getLogger();
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java
new file mode 100644
index 00000000..69f27200
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java
@@ -0,0 +1,17 @@
+package vip.mate.plugin.api;
+
+/**
+ * Runtime exception for plugin-related errors.
+ *
+ * @author MateClaw Team
+ */
+public class PluginException extends RuntimeException {
+
+ public PluginException(String message) {
+ super(message);
+ }
+
+ public PluginException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java
new file mode 100644
index 00000000..794ee3e2
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java
@@ -0,0 +1,156 @@
+package vip.mate.plugin.api;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Map;
+
+/**
+ * Plugin manifest model — deserialized from {@code mateclaw-plugin.json} in the plugin JAR root.
+ *
+ * @author MateClaw Team
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PluginManifest {
+
+ private String name;
+ private String version;
+ private String type;
+ private String displayName;
+ private String description;
+ private String entrypoint;
+
+ @JsonProperty("minPlatformVersion")
+ private String minPlatformVersion;
+
+ private String author;
+
+ private Map config;
+
+ // ==================== Getters & Setters ====================
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getVersion() { return version; }
+ public void setVersion(String version) { this.version = version; }
+
+ public String getType() { return type; }
+ public void setType(String type) { this.type = type; }
+
+ public String getDisplayName() { return displayName; }
+ public void setDisplayName(String displayName) { this.displayName = displayName; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public String getEntrypoint() { return entrypoint; }
+ public void setEntrypoint(String entrypoint) { this.entrypoint = entrypoint; }
+
+ public String getMinPlatformVersion() { return minPlatformVersion; }
+ public void setMinPlatformVersion(String minPlatformVersion) { this.minPlatformVersion = minPlatformVersion; }
+
+ public String getAuthor() { return author; }
+ public void setAuthor(String author) { this.author = author; }
+
+ public Map getConfig() { return config; }
+ public void setConfig(Map config) { this.config = config; }
+
+ /**
+ * Validate that required fields are present.
+ *
+ * @throws PluginException if validation fails
+ */
+ public void validate() {
+ if (name == null || name.isBlank()) {
+ throw new PluginException("Plugin manifest missing required field: name");
+ }
+ if (version == null || version.isBlank()) {
+ throw new PluginException("Plugin manifest missing required field: version");
+ }
+ if (entrypoint == null || entrypoint.isBlank()) {
+ throw new PluginException("Plugin manifest missing required field: entrypoint");
+ }
+ if (type == null || type.isBlank()) {
+ throw new PluginException("Plugin manifest missing required field: type");
+ }
+ // Validate type is a known enum value
+ try {
+ PluginType.valueOf(type.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new PluginException("Unknown plugin type '" + type +
+ "'. Allowed: " + java.util.Arrays.toString(PluginType.values()));
+ }
+ }
+
+ /**
+ * Check if this plugin is compatible with the given platform version.
+ *
+ * @param platformVersion current platform version (e.g. "1.1.0")
+ * @return true if compatible (minPlatformVersion <= platformVersion)
+ */
+ public boolean isCompatibleWith(String platformVersion) {
+ if (minPlatformVersion == null || minPlatformVersion.isBlank()) {
+ return true; // No constraint
+ }
+ return compareVersions(platformVersion, minPlatformVersion) >= 0;
+ }
+
+ /**
+ * Simple semver comparison: returns negative if v1 < v2, 0 if equal, positive if v1 > v2.
+ */
+ private static int compareVersions(String v1, String v2) {
+ String[] parts1 = v1.split("\\.");
+ String[] parts2 = v2.split("\\.");
+ int len = Math.max(parts1.length, parts2.length);
+ for (int i = 0; i < len; i++) {
+ int p1 = i < parts1.length ? parseIntSafe(parts1[i]) : 0;
+ int p2 = i < parts2.length ? parseIntSafe(parts2[i]) : 0;
+ if (p1 != p2) return Integer.compare(p1, p2);
+ }
+ return 0;
+ }
+
+ private static int parseIntSafe(String s) {
+ try {
+ // Strip non-numeric suffixes like "-SNAPSHOT"
+ return Integer.parseInt(s.replaceAll("[^0-9].*", ""));
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Get the plugin type as enum.
+ */
+ public PluginType getPluginType() {
+ if (type == null) return null;
+ try {
+ return PluginType.valueOf(type.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new PluginException("Unknown plugin type: " + type);
+ }
+ }
+
+ // ==================== Config Field ====================
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ConfigField {
+ private String type;
+ private boolean required;
+ private boolean secret;
+ private String description;
+
+ public String getType() { return type; }
+ public void setType(String type) { this.type = type; }
+
+ public boolean isRequired() { return required; }
+ public void setRequired(boolean required) { this.required = required; }
+
+ public boolean isSecret() { return secret; }
+ public void setSecret(boolean secret) { this.secret = secret; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+ }
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java
new file mode 100644
index 00000000..e6287ec1
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java
@@ -0,0 +1,21 @@
+package vip.mate.plugin.api;
+
+/**
+ * Plugin types covering the main extension scenarios.
+ *
+ * @author MateClaw Team
+ */
+public enum PluginType {
+
+ /** Register new agent tools */
+ TOOL,
+
+ /** Register new LLM providers */
+ PROVIDER,
+
+ /** Register new messaging channels */
+ CHANNEL,
+
+ /** Register new memory providers */
+ MEMORY
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java
new file mode 100644
index 00000000..17f891cd
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java
@@ -0,0 +1,78 @@
+package vip.mate.plugin.api.channel;
+
+/**
+ * Simplified channel adapter interface for plugins.
+ *
+ * Plugins implement this interface to register new messaging channels.
+ * The platform wraps it in an internal ChannelAdapter via a bridge.
+ *
+ * @author MateClaw Team
+ */
+public interface PluginChannelAdapter {
+
+ /**
+ * Start the channel (establish connections, register webhooks, etc.).
+ */
+ void start();
+
+ /**
+ * Stop the channel (disconnect, clean up resources).
+ */
+ void stop();
+
+ /**
+ * Whether the channel is currently running.
+ */
+ boolean isRunning();
+
+ /**
+ * The channel type identifier, e.g. "line", "whatsapp".
+ */
+ String getChannelType();
+
+ /**
+ * Human-readable display name.
+ */
+ default String getDisplayName() {
+ return getChannelType();
+ }
+
+ /**
+ * Send a text message to a target.
+ *
+ * @param targetId target identifier (user/group/channel ID)
+ * @param content message content (Markdown format)
+ */
+ void sendMessage(String targetId, String content);
+
+ /**
+ * Handle an incoming message from the channel.
+ *
+ * The platform will call this when a webhook or push message arrives.
+ * Plugins should process the message and use their internal routing logic.
+ *
+ * @param senderId sender identifier
+ * @param content message text
+ * @param rawData raw message data (JSON string) for advanced processing
+ */
+ default void onMessage(String senderId, String content, String rawData) {
+ // Default no-op; plugins override if they need to handle incoming messages
+ }
+
+ /**
+ * Whether this channel supports proactive sending (without webhook context).
+ */
+ default boolean supportsProactiveSend() {
+ return false;
+ }
+
+ /**
+ * Proactively send a message (without webhook callback context).
+ *
+ * @param targetId target identifier
+ * @param content message content
+ */
+ default void proactiveSend(String targetId, String content) {
+ throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
+ }
+}
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
new file mode 100644
index 00000000..2474d585
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
@@ -0,0 +1,77 @@
+package vip.mate.plugin.api.memory;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Memory provider interface for plugins.
+ *
+ * Mirrors the platform's internal MemoryProvider SPI with no server-internal dependencies.
+ * The platform wraps it via a bridge.
+ *
+ * @author MateClaw Team
+ */
+public interface PluginMemoryProvider {
+
+ /**
+ * Unique provider identifier, e.g. "vector_memory", "graph_memory".
+ */
+ String id();
+
+ /**
+ * Ordering for system prompt assembly and lifecycle dispatch.
+ * Lower values run first. Builtin = 0.
+ */
+ default int order() {
+ return 200;
+ }
+
+ /**
+ * Runtime availability check. Should not make network calls.
+ */
+ default boolean isAvailable() {
+ return true;
+ }
+
+ /**
+ * System prompt contribution. Called once at agent build time.
+ *
+ * @param agentId the agent ID
+ * @return text to include in system prompt, or empty string to skip
+ */
+ default String systemPromptBlock(Long agentId) {
+ return "";
+ }
+
+ /**
+ * Pre-turn context recall. Called before each LLM API call.
+ *
+ * @param agentId the agent ID
+ * @param userQuery the current user message
+ * @return context text to inject, or empty string
+ */
+ default String prefetch(Long agentId, String userQuery) {
+ return "";
+ }
+
+ /**
+ * Post-turn sync. Called after LLM response is available.
+ * Should be non-blocking (async).
+ */
+ default void syncTurn(Long agentId, String conversationId,
+ String userMessage, String assistantReply) {
+ }
+
+ /**
+ * Tool beans this provider wants to expose to the agent.
+ */
+ default List getToolBeans() {
+ return Collections.emptyList();
+ }
+
+ /**
+ * Session end hook.
+ */
+ default void onSessionEnd(Long agentId, String conversationId) {
+ }
+}
diff --git a/mateclaw-plugin-sample/pom.xml b/mateclaw-plugin-sample/pom.xml
new file mode 100644
index 00000000..a9149840
--- /dev/null
+++ b/mateclaw-plugin-sample/pom.xml
@@ -0,0 +1,67 @@
+
+
+ 4.0.0
+
+ vip.mate
+ mateclaw-plugin-sample
+ 1.0.0
+ jar
+
+ MateClaw Sample Plugin
+ A sample plugin demonstrating the MateClaw Plugin SDK
+
+
+ 21
+ 21
+ 21
+ UTF-8
+ 1.1.4
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
+
+
+
+ vip.mate
+ mateclaw-plugin-api
+ 1.1.0-SNAPSHOT
+ provided
+
+
+
+
+ org.springframework.ai
+ spring-ai-model
+ provided
+
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.16
+ provided
+
+
+
+
+
+ spring-milestones
+ https://repo.spring.io/milestone
+ false
+
+
+
diff --git a/mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java b/mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java
new file mode 100644
index 00000000..98e37fc6
--- /dev/null
+++ b/mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java
@@ -0,0 +1,51 @@
+package vip.mate.plugin.sample;
+
+import org.slf4j.Logger;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.ai.support.ToolCallbacks;
+import org.springframework.ai.tool.ToolCallback;
+import vip.mate.plugin.api.MateClawPlugin;
+import vip.mate.plugin.api.PluginContext;
+
+/**
+ * Sample plugin demonstrating the MateClaw Plugin SDK.
+ *
+ * Registers a simple "hello_world" tool that agents can call.
+ *
+ * @author MateClaw Team
+ */
+public class HelloPlugin implements MateClawPlugin {
+
+ private Logger log;
+
+ @Override
+ public void onLoad(PluginContext context) {
+ this.log = context.getLogger();
+ log.info("HelloPlugin loading...");
+
+ // Register tool callbacks from this class's @Tool methods
+ ToolCallback[] callbacks = ToolCallbacks.from(this);
+ for (ToolCallback callback : callbacks) {
+ context.registerTool(callback);
+ }
+
+ log.info("HelloPlugin loaded, registered {} tools", callbacks.length);
+ }
+
+ @Override
+ public void onEnable() {
+ if (log != null) log.info("HelloPlugin enabled");
+ }
+
+ @Override
+ public void onDisable() {
+ if (log != null) log.info("HelloPlugin disabled");
+ }
+
+ @Tool(description = "A greeting tool from the Hello World plugin. Returns a friendly greeting message for the given name.")
+ public String hello_world(
+ @ToolParam(description = "The name to greet") String name) {
+ return "Hello, " + name + "! This message comes from the MateClaw Hello World plugin.";
+ }
+}
diff --git a/mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json
new file mode 100644
index 00000000..d61aacfe
--- /dev/null
+++ b/mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json
@@ -0,0 +1,11 @@
+{
+ "name": "mateclaw-plugin-hello",
+ "version": "1.0.0",
+ "type": "tool",
+ "displayName": "Hello World Plugin",
+ "description": "A sample plugin that demonstrates the MateClaw Plugin SDK by registering a simple greeting tool.",
+ "entrypoint": "vip.mate.plugin.sample.HelloPlugin",
+ "minPlatformVersion": "1.1.0",
+ "author": "MateClaw Team",
+ "config": {}
+}
diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml
index f6584292..c2ff2390 100644
--- a/mateclaw-server/pom.xml
+++ b/mateclaw-server/pom.xml
@@ -54,6 +54,13 @@
+
+
+ vip.mate
+ mateclaw-plugin-api
+ 1.1.0-SNAPSHOT
+
+
org.springframework.boot
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
index 2c4046d6..e8369eb5 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
@@ -48,6 +48,9 @@ public class ChannelManager {
/** 运行中的渠道适配器:channelId -> adapter */
private final Map activeAdapters = new HashMap<>();
+ /** 插件注册的渠道适配器:pluginName -> adapter */
+ private final Map pluginChannels = new ConcurrentHashMap<>();
+
/** 读写锁:读操作(getAdapter 等)用读锁,写操作(start/stop/replace)用写锁 */
private final ReadWriteLock adapterLock = new ReentrantReadWriteLock();
@@ -227,12 +230,17 @@ public class ChannelManager {
}
/**
- * 按渠道类型获取适配器(返回第一个匹配的)
+ * 按渠道类型获取适配器(返回第一个匹配的,先查内置再查插件)
*/
public Optional getAdapterByType(String channelType) {
adapterLock.readLock().lock();
try {
- return activeAdapters.values().stream()
+ Optional builtin = activeAdapters.values().stream()
+ .filter(a -> a.getChannelType().equals(channelType))
+ .findFirst();
+ if (builtin.isPresent()) return builtin;
+ // Fallback to plugin channels
+ return pluginChannels.values().stream()
.filter(a -> a.getChannelType().equals(channelType))
.findFirst();
} finally {
@@ -241,12 +249,14 @@ public class ChannelManager {
}
/**
- * 获取所有运行中的渠道适配器
+ * 获取所有运行中的渠道适配器(含插件渠道)
*/
public Collection getActiveAdapters() {
adapterLock.readLock().lock();
try {
- return List.copyOf(activeAdapters.values());
+ List all = new ArrayList<>(activeAdapters.values());
+ all.addAll(pluginChannels.values());
+ return List.copyOf(all);
} finally {
adapterLock.readLock().unlock();
}
@@ -298,10 +308,12 @@ public class ChannelManager {
}
/**
- * 判断是否支持该渠道类型
+ * 判断是否支持该渠道类型(含插件渠道)
*/
public boolean isSupported(String channelType) {
- return SUPPORTED_TYPES.contains(channelType);
+ if (SUPPORTED_TYPES.contains(channelType)) return true;
+ return pluginChannels.values().stream()
+ .anyMatch(a -> a.getChannelType().equals(channelType));
}
// ==================== 主动推送 ====================
@@ -345,6 +357,35 @@ public class ChannelManager {
sendToChannel(session.getChannelId(), session.getTargetId(), content);
}
+ // ==================== 插件渠道管理 ====================
+
+ /**
+ * Register a channel adapter from a plugin.
+ *
+ * @param pluginName the plugin name (used as key for unregistration)
+ * @param adapter the channel adapter
+ */
+ public void registerPluginChannel(String pluginName, ChannelAdapter adapter) {
+ try {
+ adapter.start();
+ pluginChannels.put(pluginName, adapter);
+ log.info("Plugin channel registered: {} (type={})", pluginName, adapter.getChannelType());
+ } catch (Exception e) {
+ log.error("Failed to start plugin channel {}: {}", pluginName, e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Unregister a plugin channel.
+ */
+ public void unregisterPluginChannel(String pluginName) {
+ ChannelAdapter adapter = pluginChannels.remove(pluginName);
+ if (adapter != null) {
+ stopAdapterSafely(adapter, "unregisterPluginChannel");
+ log.info("Plugin channel unregistered: {}", pluginName);
+ }
+ }
+
// ==================== 内部方法 ====================
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
index 7519ae47..951eb3ec 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
@@ -12,7 +12,10 @@ import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper;
+import org.springframework.ai.chat.model.ChatModel;
+
import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Service
@@ -24,6 +27,32 @@ public class ModelProviderService {
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper = new ObjectMapper();
+ /** Plugin-registered ChatModel instances: providerId -> ChatModel */
+ private final Map pluginChatModels = new ConcurrentHashMap<>();
+
+ /**
+ * Register a ChatModel from a plugin.
+ */
+ public void registerPluginChatModel(String providerId, ChatModel chatModel) {
+ pluginChatModels.put(providerId, chatModel);
+ }
+
+ /**
+ * Unregister a plugin ChatModel.
+ */
+ public void unregisterPluginChatModel(String providerId) {
+ pluginChatModels.remove(providerId);
+ }
+
+ /**
+ * Get a plugin-registered ChatModel.
+ *
+ * @return the ChatModel, or null if not registered by a plugin
+ */
+ public ChatModel getPluginChatModel(String providerId) {
+ return pluginChatModels.get(providerId);
+ }
+
public List listProviders() {
List providers = modelProviderMapper.selectList(new LambdaQueryWrapper()
.orderByDesc(ModelProviderEntity::getIsLocal)
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
index ec4d2967..bd5b5423 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
@@ -29,6 +29,9 @@ public class MemoryManager {
private final List providers;
+ /** External plugin memory provider (single-select constraint) */
+ private volatile MemoryProvider externalPluginProvider = null;
+
public MemoryManager(List allProviders, MemoryProperties properties) {
Set disabled = properties.getDisabledProviders();
this.providers = allProviders.stream()
@@ -183,6 +186,55 @@ public class MemoryManager {
+ "";
}
+ // ==================== Plugin Provider Registration ====================
+
+ /**
+ * Register an external plugin memory provider.
+ * Only one external provider is allowed at a time (single-select constraint).
+ *
+ * @param provider the memory provider to register
+ * @throws vip.mate.plugin.api.PluginException if an external provider is already registered
+ */
+ public synchronized void registerPluginProvider(MemoryProvider provider) {
+ if (externalPluginProvider != null) {
+ throw new vip.mate.plugin.api.PluginException(
+ "Only one external memory provider allowed. Current: " + externalPluginProvider.id());
+ }
+ if (!provider.isAvailable()) {
+ log.warn("[MemoryManager] Plugin provider '{}' is not available, skipping", provider.id());
+ return;
+ }
+ externalPluginProvider = provider;
+ providers.add(provider);
+ providers.sort(Comparator.comparingInt(MemoryProvider::order));
+ log.info("[MemoryManager] Plugin provider registered: {}", provider.id());
+ }
+
+ /**
+ * Unregister the external plugin memory provider.
+ */
+ public synchronized void unregisterPluginProvider(String providerId) {
+ if (externalPluginProvider != null && externalPluginProvider.id().equals(providerId)) {
+ providers.removeIf(p -> p.id().equals(providerId));
+ externalPluginProvider = null;
+ log.info("[MemoryManager] Plugin provider unregistered: {}", providerId);
+ }
+ }
+
+ /**
+ * Whether an external plugin memory provider is registered.
+ */
+ public boolean hasExternalProvider() {
+ return externalPluginProvider != null;
+ }
+
+ /**
+ * Get the external plugin memory provider's ID.
+ */
+ public String getExternalProviderName() {
+ return externalPluginProvider != null ? externalPluginProvider.id() : null;
+ }
+
// ==================== Accessors ====================
public List getProviders() {
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java
new file mode 100644
index 00000000..2c455883
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java
@@ -0,0 +1,47 @@
+package vip.mate.plugin;
+
+import lombok.Data;
+import vip.mate.plugin.api.MateClawPlugin;
+import vip.mate.plugin.api.PluginManifest;
+
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Internal state holder for a loaded plugin.
+ *
+ * @author MateClaw Team
+ */
+@Data
+public class LoadedPlugin {
+
+ private final PluginManifest manifest;
+ private final MateClawPlugin plugin;
+ private final URLClassLoader classLoader;
+
+ /** Set after construction (circular reference with PluginContextImpl) */
+ private PluginContextImpl context;
+
+ /** Names of tools registered by this plugin */
+ private final List registeredTools = new ArrayList<>();
+
+ /** Channel types registered by this plugin */
+ private final List registeredChannels = new ArrayList<>();
+
+ /** Provider ID registered by this plugin (null if none) */
+ private String registeredProvider;
+
+ /** Memory provider ID registered by this plugin (null if none) */
+ private String registeredMemoryProvider;
+
+ /** Whether the plugin is currently enabled */
+ private boolean enabled = true;
+
+ public LoadedPlugin(PluginManifest manifest, MateClawPlugin plugin,
+ URLClassLoader classLoader) {
+ this.manifest = manifest;
+ this.plugin = plugin;
+ this.classLoader = classLoader;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java
new file mode 100644
index 00000000..554f4db5
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java
@@ -0,0 +1,121 @@
+package vip.mate.plugin;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.tool.ToolCallback;
+import vip.mate.channel.ChannelManager;
+import vip.mate.llm.service.ModelProviderService;
+import vip.mate.memory.spi.MemoryManager;
+import vip.mate.plugin.api.PluginContext;
+import vip.mate.plugin.api.PluginException;
+import vip.mate.plugin.api.PluginManifest;
+import vip.mate.plugin.api.channel.PluginChannelAdapter;
+import vip.mate.plugin.api.memory.PluginMemoryProvider;
+import vip.mate.plugin.bridge.PluginChannelBridge;
+import vip.mate.plugin.bridge.PluginMemoryBridge;
+import vip.mate.tool.ToolRegistry;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Platform-side implementation of {@link PluginContext}.
+ * Bridges plugin registrations to the corresponding platform services.
+ *
+ * @author MateClaw Team
+ */
+public class PluginContextImpl implements PluginContext {
+
+ private final LoadedPlugin loadedPlugin;
+ private final PluginManifest manifest;
+ private final ToolRegistry toolRegistry;
+ private final ChannelManager channelManager;
+ private final MemoryManager memoryManager;
+ private final ModelProviderService modelProviderService;
+ private final Map configMap;
+ private final Logger logger;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ public PluginContextImpl(LoadedPlugin loadedPlugin,
+ PluginManifest manifest,
+ ToolRegistry toolRegistry,
+ ChannelManager channelManager,
+ MemoryManager memoryManager,
+ ModelProviderService modelProviderService,
+ String configJson) {
+ this.loadedPlugin = loadedPlugin;
+ this.manifest = manifest;
+ this.toolRegistry = toolRegistry;
+ this.channelManager = channelManager;
+ this.memoryManager = memoryManager;
+ this.modelProviderService = modelProviderService;
+ this.logger = LoggerFactory.getLogger("plugin." + manifest.getName());
+ this.configMap = parseConfig(configJson);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map parseConfig(String configJson) {
+ if (configJson == null || configJson.isBlank()) {
+ return Map.of();
+ }
+ try {
+ return objectMapper.readValue(configJson, Map.class);
+ } catch (Exception e) {
+ logger.warn("Failed to parse plugin config JSON: {}", e.getMessage());
+ return Map.of();
+ }
+ }
+
+ @Override
+ public void registerTool(ToolCallback tool) {
+ registerTool(tool, () -> true);
+ }
+
+ @Override
+ public void registerTool(ToolCallback tool, Supplier availabilityCheck) {
+ toolRegistry.registerPluginTool(tool, availabilityCheck);
+ loadedPlugin.getRegisteredTools().add(tool.getToolDefinition().name());
+ }
+
+ @Override
+ public void registerProvider(String providerId, ChatModel chatModel) {
+ modelProviderService.registerPluginChatModel(providerId, chatModel);
+ loadedPlugin.setRegisteredProvider(providerId);
+ }
+
+ @Override
+ public void registerChannel(PluginChannelAdapter channel) {
+ PluginChannelBridge bridge = new PluginChannelBridge(channel);
+ channelManager.registerPluginChannel(manifest.getName(), bridge);
+ loadedPlugin.getRegisteredChannels().add(channel.getChannelType());
+ }
+
+ @Override
+ public void registerMemoryProvider(PluginMemoryProvider provider) {
+ if (memoryManager.hasExternalProvider()) {
+ throw new PluginException(
+ "Only one external memory provider allowed. Current: " +
+ memoryManager.getExternalProviderName());
+ }
+ PluginMemoryBridge bridge = new PluginMemoryBridge(provider);
+ memoryManager.registerPluginProvider(bridge);
+ loadedPlugin.setRegisteredMemoryProvider(provider.id());
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getConfig(String key, Class type) {
+ Object value = configMap.get(key);
+ if (value == null) return null;
+ if (type.isInstance(value)) return (T) value;
+ return objectMapper.convertValue(value, type);
+ }
+
+ @Override
+ public Logger getLogger() {
+ return logger;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java
new file mode 100644
index 00000000..5d3322eb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java
@@ -0,0 +1,562 @@
+package vip.mate.plugin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import vip.mate.channel.ChannelManager;
+import vip.mate.llm.service.ModelProviderService;
+import vip.mate.memory.spi.MemoryManager;
+import vip.mate.plugin.api.MateClawPlugin;
+import vip.mate.plugin.api.PluginException;
+import vip.mate.plugin.api.PluginManifest;
+import vip.mate.plugin.model.PluginEntity;
+import vip.mate.plugin.model.PluginInfo;
+import vip.mate.plugin.repository.PluginMapper;
+import vip.mate.tool.ToolRegistry;
+import vip.mate.workspace.core.model.WorkspaceEntity;
+import vip.mate.workspace.core.service.WorkspaceService;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.*;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.jar.JarFile;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+
+/**
+ * Plugin lifecycle manager.
+ *
+ * Discovers, validates, loads, and manages plugins from JAR files.
+ * Supports three discovery paths with priority: workspace > user-global > classpath.
+ * Plugins are loaded on application startup and can be enabled/disabled at runtime.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PluginManager {
+
+ /** Current platform version for minPlatformVersion compatibility check */
+ private static final String PLATFORM_VERSION = "1.1.0";
+
+ private final PluginProperties pluginProperties;
+ private final PluginMapper pluginMapper;
+ private final ToolRegistry toolRegistry;
+ private final ChannelManager channelManager;
+ private final MemoryManager memoryManager;
+ private final ModelProviderService modelProviderService;
+ private final Optional workspaceService;
+
+ private final Map plugins = new ConcurrentHashMap<>();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /**
+ * Load all plugins on application startup.
+ * Scans three paths in priority order: workspace > user-global.
+ * Higher priority plugins shadow lower priority ones with the same name.
+ */
+ @EventListener(ApplicationReadyEvent.class)
+ @Order(250)
+ public void loadAllPlugins() {
+ if (!pluginProperties.isEnabled()) {
+ log.info("Plugin system is disabled");
+ return;
+ }
+
+ // Collect plugin JARs from all discovery paths in priority order
+ List discoveredJars = discoverPluginJars();
+
+ int loaded = 0;
+ int skipped = 0;
+ int failed = 0;
+
+ for (Path jar : discoveredJars) {
+ try {
+ // Read manifest to check name before loading
+ PluginManifest manifest = readManifest(jar);
+ if (plugins.containsKey(manifest.getName())) {
+ log.info("Plugin {} already loaded from higher-priority path, skipping: {}",
+ manifest.getName(), jar);
+ skipped++;
+ continue;
+ }
+ loadPlugin(jar);
+ loaded++;
+ } catch (Exception e) {
+ failed++;
+ log.error("Failed to load plugin from {}: {}", jar.getFileName(), e.getMessage(), e);
+ recordError(jar, e);
+ }
+ }
+
+ log.info("Plugin loading complete: {} loaded, {} skipped (duplicate), {} failed", loaded, skipped, failed);
+ }
+
+ /**
+ * Discover plugin JARs from all paths in priority order.
+ * Workspace plugins (highest priority) come first, then user-global.
+ */
+ private List discoverPluginJars() {
+ List jars = new ArrayList<>();
+
+ // 1. Workspace-level plugins (highest priority)
+ workspaceService.ifPresent(ws -> {
+ try {
+ WorkspaceEntity defaultWs = ws.getBySlug(WorkspaceService.DEFAULT_SLUG);
+ if (defaultWs != null && defaultWs.getBasePath() != null) {
+ Path workspacePluginDir = Paths.get(defaultWs.getBasePath(), "plugins");
+ if (Files.isDirectory(workspacePluginDir)) {
+ scanJars(workspacePluginDir, jars, "workspace");
+ }
+ }
+ } catch (Exception e) {
+ log.debug("Workspace plugin scan skipped: {}", e.getMessage());
+ }
+ });
+
+ // 2. User-global plugins
+ Path userDir = Paths.get(pluginProperties.getUserDir());
+ try {
+ Files.createDirectories(userDir);
+ scanJars(userDir, jars, "user-global");
+ } catch (IOException e) {
+ log.warn("Failed to scan user plugin directory {}: {}", userDir, e.getMessage());
+ }
+
+ log.info("Discovered {} plugin JAR(s) across all paths", jars.size());
+ return jars;
+ }
+
+ private void scanJars(Path dir, List target, String source) {
+ try (Stream stream = Files.list(dir)) {
+ List found = stream
+ .filter(p -> p.toString().endsWith(".jar"))
+ .toList();
+ if (!found.isEmpty()) {
+ log.info("Found {} plugin JAR(s) in {} ({})", found.size(), dir, source);
+ target.addAll(found);
+ }
+ } catch (IOException e) {
+ log.warn("Failed to list {} plugin directory {}: {}", source, dir, e.getMessage());
+ }
+ }
+
+ /**
+ * Load a single plugin from a JAR file.
+ * Ensures ClassLoader cleanup on any failure, and rolls back registrations
+ * if onLoad/onEnable throws.
+ */
+ public void loadPlugin(Path jarPath) throws Exception {
+ log.info("Loading plugin from: {}", jarPath.getFileName());
+
+ // 1. Read and validate manifest
+ PluginManifest manifest = readManifest(jarPath);
+ manifest.validate();
+
+ // Check platform version compatibility
+ if (!manifest.isCompatibleWith(PLATFORM_VERSION)) {
+ throw new PluginException("Plugin " + manifest.getName() + " requires platform version " +
+ manifest.getMinPlatformVersion() + " but current is " + PLATFORM_VERSION);
+ }
+
+ String pluginName = manifest.getName();
+
+ // 2. Check if already loaded
+ if (plugins.containsKey(pluginName)) {
+ log.warn("Plugin {} already loaded, skipping: {}", pluginName, jarPath);
+ return;
+ }
+
+ // 3. Check DB for disabled state
+ PluginEntity existing = findByName(pluginName);
+ if (existing != null && !Boolean.TRUE.equals(existing.getEnabled())) {
+ log.info("Plugin {} is disabled in database, skipping", pluginName);
+ upsertEntity(manifest, jarPath.toString(), "DISABLED", null);
+ return;
+ }
+
+ // 4. Create ClassLoader — all subsequent failures must close it
+ URLClassLoader classLoader = new URLClassLoader(
+ new URL[]{jarPath.toUri().toURL()},
+ getClass().getClassLoader()
+ );
+
+ try {
+ // 5. Instantiate entrypoint
+ Class> entryClass = classLoader.loadClass(manifest.getEntrypoint());
+ if (!MateClawPlugin.class.isAssignableFrom(entryClass)) {
+ throw new PluginException("Entrypoint class " + manifest.getEntrypoint() +
+ " does not implement MateClawPlugin");
+ }
+ MateClawPlugin plugin = (MateClawPlugin) entryClass.getDeclaredConstructor().newInstance();
+
+ // 6. Create context and loaded plugin
+ String configJson = existing != null ? existing.getConfigJson() : null;
+ LoadedPlugin loadedPlugin = new LoadedPlugin(manifest, plugin, classLoader);
+
+ PluginContextImpl context = new PluginContextImpl(
+ loadedPlugin, manifest,
+ toolRegistry, channelManager, memoryManager, modelProviderService,
+ configJson
+ );
+ loadedPlugin.setContext(context);
+
+ // 7. Call lifecycle methods — rollback registrations on failure
+ try {
+ plugin.onLoad(context);
+ plugin.onEnable();
+ } catch (Exception e) {
+ log.error("Plugin {} lifecycle failed, rolling back registrations: {}", pluginName, e.getMessage());
+ rollbackRegistrations(loadedPlugin);
+ throw e;
+ }
+
+ // 8. Register successfully
+ plugins.put(pluginName, loadedPlugin);
+ upsertEntity(manifest, jarPath.toString(), "ENABLED", null);
+
+ log.info("Plugin loaded: {} v{} (type={}, tools={}, channels={})",
+ pluginName, manifest.getVersion(), manifest.getType(),
+ loadedPlugin.getRegisteredTools().size(),
+ loadedPlugin.getRegisteredChannels().size());
+
+ } catch (Exception e) {
+ // ClassLoader cleanup on any failure
+ try {
+ classLoader.close();
+ } catch (IOException closeEx) {
+ log.warn("Failed to close ClassLoader for {}: {}", jarPath.getFileName(), closeEx.getMessage());
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Rollback all registrations made by a plugin during failed onLoad/onEnable.
+ */
+ private void rollbackRegistrations(LoadedPlugin loaded) {
+ for (String toolName : loaded.getRegisteredTools()) {
+ try { toolRegistry.unregisterPluginTool(toolName); } catch (Exception e) { /* best effort */ }
+ }
+ if (!loaded.getRegisteredChannels().isEmpty()) {
+ try { channelManager.unregisterPluginChannel(loaded.getManifest().getName()); } catch (Exception e) { /* best effort */ }
+ }
+ if (loaded.getRegisteredMemoryProvider() != null) {
+ try { memoryManager.unregisterPluginProvider(loaded.getRegisteredMemoryProvider()); } catch (Exception e) { /* best effort */ }
+ }
+ if (loaded.getRegisteredProvider() != null) {
+ try { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); } catch (Exception e) { /* best effort */ }
+ }
+ }
+
+ /**
+ * Disable a plugin by name.
+ */
+ public void disablePlugin(String name) {
+ LoadedPlugin loaded = plugins.get(name);
+ if (loaded == null) {
+ throw new PluginException("Plugin not found or not running: " + name);
+ }
+
+ // Call onDisable
+ try {
+ loaded.getPlugin().onDisable();
+ } catch (Exception e) {
+ log.warn("Plugin {} onDisable() threw exception: {}", name, e.getMessage());
+ }
+
+ // Unregister all capabilities
+ int toolsRemoved = 0;
+ for (String toolName : loaded.getRegisteredTools()) {
+ toolRegistry.unregisterPluginTool(toolName);
+ toolsRemoved++;
+ }
+ channelManager.unregisterPluginChannel(name);
+ int channelsRemoved = loaded.getRegisteredChannels().size();
+
+ String memoryRemoved = null;
+ if (loaded.getRegisteredMemoryProvider() != null) {
+ memoryManager.unregisterPluginProvider(loaded.getRegisteredMemoryProvider());
+ memoryRemoved = loaded.getRegisteredMemoryProvider();
+ }
+
+ String providerRemoved = null;
+ if (loaded.getRegisteredProvider() != null) {
+ modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider());
+ providerRemoved = loaded.getRegisteredProvider();
+ }
+
+ loaded.setEnabled(false);
+ plugins.remove(name);
+ updateStatus(name, false, "DISABLED", null);
+
+ log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={})",
+ name, toolsRemoved, channelsRemoved,
+ providerRemoved != null ? providerRemoved : "none",
+ memoryRemoved != null ? memoryRemoved : "none");
+ }
+
+ /**
+ * Enable a previously disabled plugin.
+ * Validates JAR still exists before re-loading.
+ */
+ public void enablePlugin(String name) {
+ PluginEntity entity = findByName(name);
+ if (entity == null) {
+ throw new PluginException("Plugin not found in database: " + name);
+ }
+ if (entity.getJarPath() == null) {
+ throw new PluginException("Plugin JAR path unknown for: " + name);
+ }
+
+ // Validate JAR still exists on disk
+ Path jarPath = Paths.get(entity.getJarPath());
+ if (!Files.exists(jarPath)) {
+ updateStatus(name, false, "ERROR", "JAR file not found: " + entity.getJarPath());
+ throw new PluginException("Plugin JAR file no longer exists: " + entity.getJarPath());
+ }
+
+ updateStatus(name, true, "LOADING", null);
+
+ try {
+ loadPlugin(jarPath);
+ } catch (Exception e) {
+ updateStatus(name, false, "ERROR", e.getMessage());
+ throw new PluginException("Failed to re-enable plugin " + name + ": " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * List all known plugins (loaded + DB-only).
+ * Secret config values are redacted in the response.
+ */
+ @SuppressWarnings("unchecked")
+ public List listPlugins() {
+ Map result = new LinkedHashMap<>();
+
+ // In-memory loaded plugins
+ for (Map.Entry entry : plugins.entrySet()) {
+ LoadedPlugin loaded = entry.getValue();
+ PluginManifest m = loaded.getManifest();
+ result.put(entry.getKey(), PluginInfo.builder()
+ .name(m.getName())
+ .version(m.getVersion())
+ .type(m.getType())
+ .displayName(m.getDisplayName())
+ .description(m.getDescription())
+ .author(m.getAuthor())
+ .enabled(loaded.isEnabled())
+ .status("ENABLED")
+ .registeredTools(List.copyOf(loaded.getRegisteredTools()))
+ .registeredChannels(List.copyOf(loaded.getRegisteredChannels()))
+ .registeredProvider(loaded.getRegisteredProvider())
+ .registeredMemoryProvider(loaded.getRegisteredMemoryProvider())
+ .configSchema(buildConfigSchema(m))
+ .currentConfig(buildRedactedConfig(loaded))
+ .build());
+ }
+
+ // DB-only entries (disabled plugins not in memory)
+ List dbPlugins = pluginMapper.selectList(new LambdaQueryWrapper<>());
+ for (PluginEntity entity : dbPlugins) {
+ if (!result.containsKey(entity.getName())) {
+ result.put(entity.getName(), PluginInfo.builder()
+ .name(entity.getName())
+ .version(entity.getVersion())
+ .type(entity.getPluginType())
+ .displayName(entity.getDisplayName())
+ .description(entity.getDescription())
+ .author(entity.getAuthor())
+ .enabled(Boolean.TRUE.equals(entity.getEnabled()))
+ .status(entity.getStatus())
+ .errorMessage(entity.getErrorMessage())
+ .jarPath(entity.getJarPath())
+ .registeredTools(List.of())
+ .registeredChannels(List.of())
+ .build());
+ }
+ }
+
+ return new ArrayList<>(result.values());
+ }
+
+ /**
+ * Get a single plugin's info by name.
+ */
+ public PluginInfo getPlugin(String name) {
+ return listPlugins().stream()
+ .filter(p -> p.getName().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new PluginException("Plugin not found: " + name));
+ }
+
+ /**
+ * Update a plugin's configuration.
+ */
+ public void updateConfig(String name, Map config) {
+ PluginEntity entity = findByName(name);
+ if (entity == null) {
+ throw new PluginException("Plugin not found: " + name);
+ }
+
+ // Validate config keys against manifest if plugin is loaded
+ LoadedPlugin loaded = plugins.get(name);
+ if (loaded != null && loaded.getManifest().getConfig() != null) {
+ Map schema = loaded.getManifest().getConfig();
+ for (String key : config.keySet()) {
+ if (!schema.containsKey(key)) {
+ log.warn("Plugin {} config: unknown key '{}' (not in manifest schema)", name, key);
+ }
+ }
+ // Check required fields
+ for (Map.Entry schemaEntry : schema.entrySet()) {
+ if (schemaEntry.getValue().isRequired() && !config.containsKey(schemaEntry.getKey())) {
+ throw new PluginException("Missing required config field: " + schemaEntry.getKey());
+ }
+ }
+ }
+
+ try {
+ entity.setConfigJson(objectMapper.writeValueAsString(config));
+ pluginMapper.updateById(entity);
+ log.info("Plugin config updated: {}", name);
+ } catch (PluginException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new PluginException("Failed to update config for " + name + ": " + e.getMessage(), e);
+ }
+ }
+
+ // ==================== Config Schema & Redaction ====================
+
+ /**
+ * Build config schema map from manifest for frontend display.
+ */
+ private Map buildConfigSchema(PluginManifest manifest) {
+ if (manifest.getConfig() == null || manifest.getConfig().isEmpty()) {
+ return null;
+ }
+ Map schema = new LinkedHashMap<>();
+ manifest.getConfig().forEach((key, field) -> {
+ Map fieldInfo = new LinkedHashMap<>();
+ fieldInfo.put("type", field.getType());
+ fieldInfo.put("required", field.isRequired());
+ fieldInfo.put("secret", field.isSecret());
+ fieldInfo.put("description", field.getDescription());
+ schema.put(key, fieldInfo);
+ });
+ return schema;
+ }
+
+ /**
+ * Build config map with secret values redacted.
+ */
+ @SuppressWarnings("unchecked")
+ private Map buildRedactedConfig(LoadedPlugin loaded) {
+ PluginEntity entity = findByName(loaded.getManifest().getName());
+ if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) {
+ return null;
+ }
+ try {
+ Map config = objectMapper.readValue(entity.getConfigJson(), Map.class);
+ // Redact secret fields
+ Map schema = loaded.getManifest().getConfig();
+ if (schema != null) {
+ for (Map.Entry schemaEntry : schema.entrySet()) {
+ if (schemaEntry.getValue().isSecret() && config.containsKey(schemaEntry.getKey())) {
+ Object val = config.get(schemaEntry.getKey());
+ if (val != null && !val.toString().isBlank()) {
+ config.put(schemaEntry.getKey(), "****");
+ }
+ }
+ }
+ }
+ return config;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ // ==================== Internal Methods ====================
+
+ private PluginManifest readManifest(Path jarPath) throws IOException {
+ try (JarFile jarFile = new JarFile(jarPath.toFile())) {
+ ZipEntry entry = jarFile.getEntry("mateclaw-plugin.json");
+ if (entry == null) {
+ throw new PluginException("No mateclaw-plugin.json found in " + jarPath);
+ }
+ try (InputStream is = jarFile.getInputStream(entry)) {
+ return objectMapper.readValue(is, PluginManifest.class);
+ }
+ }
+ }
+
+ private PluginEntity findByName(String name) {
+ return pluginMapper.selectOne(new LambdaQueryWrapper()
+ .eq(PluginEntity::getName, name));
+ }
+
+ private void upsertEntity(PluginManifest manifest, String jarPath, String status, String error) {
+ PluginEntity existing = findByName(manifest.getName());
+ if (existing != null) {
+ existing.setVersion(manifest.getVersion());
+ existing.setPluginType(manifest.getType());
+ existing.setDisplayName(manifest.getDisplayName());
+ existing.setDescription(manifest.getDescription());
+ existing.setAuthor(manifest.getAuthor());
+ existing.setEntrypoint(manifest.getEntrypoint());
+ existing.setJarPath(jarPath);
+ existing.setStatus(status);
+ existing.setErrorMessage(error);
+ existing.setEnabled(!"DISABLED".equals(status) && !"ERROR".equals(status));
+ pluginMapper.updateById(existing);
+ } else {
+ PluginEntity entity = new PluginEntity();
+ entity.setName(manifest.getName());
+ entity.setVersion(manifest.getVersion());
+ entity.setPluginType(manifest.getType());
+ entity.setDisplayName(manifest.getDisplayName());
+ entity.setDescription(manifest.getDescription());
+ entity.setAuthor(manifest.getAuthor());
+ entity.setEntrypoint(manifest.getEntrypoint());
+ entity.setJarPath(jarPath);
+ entity.setStatus(status);
+ entity.setErrorMessage(error);
+ entity.setEnabled(!"DISABLED".equals(status) && !"ERROR".equals(status));
+ entity.setCreateTime(LocalDateTime.now());
+ entity.setUpdateTime(LocalDateTime.now());
+ entity.setConfigJson("{}");
+ pluginMapper.insert(entity);
+ }
+ }
+
+ private void updateStatus(String name, boolean enabled, String status, String error) {
+ PluginEntity entity = findByName(name);
+ if (entity != null) {
+ entity.setEnabled(enabled);
+ entity.setStatus(status);
+ entity.setErrorMessage(error);
+ pluginMapper.updateById(entity);
+ }
+ }
+
+ private void recordError(Path jarPath, Exception e) {
+ try {
+ PluginManifest manifest = readManifest(jarPath);
+ upsertEntity(manifest, jarPath.toString(), "ERROR", e.getMessage());
+ } catch (Exception ex) {
+ log.warn("Cannot record plugin error (manifest unreadable): {} — {}", jarPath.getFileName(), ex.getMessage());
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java
new file mode 100644
index 00000000..24f7f3f7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java
@@ -0,0 +1,22 @@
+package vip.mate.plugin;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Plugin SDK configuration properties.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "mateclaw.plugin")
+public class PluginProperties {
+
+ /** Whether the plugin system is enabled */
+ private boolean enabled = true;
+
+ /** User-global plugin directory */
+ private String userDir = System.getProperty("user.home") + "/.mateclaw/plugins";
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java
new file mode 100644
index 00000000..76528b4a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java
@@ -0,0 +1,71 @@
+package vip.mate.plugin.bridge;
+
+import vip.mate.channel.ChannelAdapter;
+import vip.mate.channel.ChannelMessage;
+import vip.mate.plugin.api.channel.PluginChannelAdapter;
+
+/**
+ * Bridge that wraps a plugin's {@link PluginChannelAdapter} into the platform's
+ * internal {@link ChannelAdapter} interface.
+ *
+ * @author MateClaw Team
+ */
+public class PluginChannelBridge implements ChannelAdapter {
+
+ private final PluginChannelAdapter delegate;
+
+ public PluginChannelBridge(PluginChannelAdapter delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void start() {
+ delegate.start();
+ }
+
+ @Override
+ public void stop() {
+ delegate.stop();
+ }
+
+ @Override
+ public boolean isRunning() {
+ return delegate.isRunning();
+ }
+
+ @Override
+ public void onMessage(ChannelMessage message) {
+ // Bridge: convert internal ChannelMessage to simplified plugin format
+ String rawData = message.getRawPayload() != null ? message.getRawPayload().toString() : null;
+ delegate.onMessage(
+ message.getSenderId(),
+ message.getContent(),
+ rawData
+ );
+ }
+
+ @Override
+ public void sendMessage(String targetId, String content) {
+ delegate.sendMessage(targetId, content);
+ }
+
+ @Override
+ public boolean supportsProactiveSend() {
+ return delegate.supportsProactiveSend();
+ }
+
+ @Override
+ public void proactiveSend(String targetId, String content) {
+ delegate.proactiveSend(targetId, content);
+ }
+
+ @Override
+ public String getChannelType() {
+ return delegate.getChannelType();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return delegate.getDisplayName();
+ }
+}
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
new file mode 100644
index 00000000..23ce8c45
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java
@@ -0,0 +1,64 @@
+package vip.mate.plugin.bridge;
+
+import vip.mate.memory.spi.MemoryProvider;
+import vip.mate.plugin.api.memory.PluginMemoryProvider;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Bridge that wraps a plugin's {@link PluginMemoryProvider} into the platform's
+ * internal {@link MemoryProvider} interface.
+ *
+ * @author MateClaw Team
+ */
+public class PluginMemoryBridge implements MemoryProvider {
+
+ private final PluginMemoryProvider delegate;
+
+ public PluginMemoryBridge(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 void syncTurn(Long agentId, String conversationId,
+ String userMessage, String assistantReply) {
+ delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
+ }
+
+ @Override
+ public List getToolBeans() {
+ List beans = delegate.getToolBeans();
+ return beans != null ? beans : Collections.emptyList();
+ }
+
+ @Override
+ public void onSessionEnd(Long agentId, String conversationId) {
+ delegate.onSessionEnd(agentId, conversationId);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java
new file mode 100644
index 00000000..dd24ca49
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java
@@ -0,0 +1,60 @@
+package vip.mate.plugin.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import vip.mate.common.result.R;
+import vip.mate.plugin.PluginManager;
+import vip.mate.plugin.model.PluginInfo;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Plugin management REST API.
+ *
+ * @author MateClaw Team
+ */
+@Tag(name = "Plugin Management")
+@RestController
+@RequestMapping("/api/v1/plugins")
+@RequiredArgsConstructor
+public class PluginController {
+
+ private final PluginManager pluginManager;
+
+ @Operation(summary = "List all plugins")
+ @GetMapping
+ public R> list() {
+ return R.ok(pluginManager.listPlugins());
+ }
+
+ @Operation(summary = "Get plugin detail")
+ @GetMapping("/{name}")
+ public R get(@PathVariable String name) {
+ return R.ok(pluginManager.getPlugin(name));
+ }
+
+ @Operation(summary = "Disable a plugin")
+ @PostMapping("/{name}/disable")
+ public R disable(@PathVariable String name) {
+ pluginManager.disablePlugin(name);
+ return R.ok();
+ }
+
+ @Operation(summary = "Enable a plugin")
+ @PostMapping("/{name}/enable")
+ public R enable(@PathVariable String name) {
+ pluginManager.enablePlugin(name);
+ return R.ok();
+ }
+
+ @Operation(summary = "Update plugin configuration")
+ @PutMapping("/{name}/config")
+ public R updateConfig(@PathVariable String name,
+ @RequestBody Map config) {
+ pluginManager.updateConfig(name, config);
+ return R.ok();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java
new file mode 100644
index 00000000..cf2c77fd
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java
@@ -0,0 +1,66 @@
+package vip.mate.plugin.model;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Plugin entity — persists plugin state in mate_plugin table.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@TableName("mate_plugin")
+public class PluginEntity {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** Plugin name (unique identifier from manifest) */
+ private String name;
+
+ /** Plugin version */
+ private String version;
+
+ /** Plugin type: tool / provider / channel / memory */
+ private String pluginType;
+
+ /** Display name */
+ private String displayName;
+
+ /** Description */
+ private String description;
+
+ /** Author */
+ private String author;
+
+ /** Fully qualified entrypoint class name */
+ private String entrypoint;
+
+ /** JAR file path on disk */
+ private String jarPath;
+
+ /** Plugin configuration as JSON */
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
+ private String configJson;
+
+ /** Whether the plugin is enabled */
+ private Boolean enabled;
+
+ /** Runtime status: LOADED / ENABLED / DISABLED / ERROR */
+ private String status;
+
+ /** Error message if loading failed */
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
+ private String errorMessage;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java
new file mode 100644
index 00000000..817d5592
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java
@@ -0,0 +1,46 @@
+package vip.mate.plugin.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Plugin info DTO for REST API responses.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@Builder
+public class PluginInfo {
+
+ private String name;
+ private String version;
+ private String type;
+ private String displayName;
+ private String description;
+ private String author;
+ private boolean enabled;
+ private String status;
+ private String errorMessage;
+ private String jarPath;
+
+ /** Names of tools registered by this plugin */
+ private List registeredTools;
+
+ /** Channel types registered by this plugin */
+ private List registeredChannels;
+
+ /** Provider ID registered by this plugin (null if none) */
+ private String registeredProvider;
+
+ /** Memory provider ID registered by this plugin (null if none) */
+ private String registeredMemoryProvider;
+
+ /** Plugin config schema (from manifest) */
+ private Map configSchema;
+
+ /** Current config values */
+ private Map currentConfig;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java b/mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java
new file mode 100644
index 00000000..0384e3a0
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java
@@ -0,0 +1,14 @@
+package vip.mate.plugin.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.plugin.model.PluginEntity;
+
+/**
+ * MyBatis Plus mapper for mate_plugin table.
+ *
+ * @author MateClaw Team
+ */
+@Mapper
+public interface PluginMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java
index 945e3279..24cdadd2 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java
@@ -21,6 +21,8 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
@@ -39,6 +41,31 @@ public class ToolRegistry {
private final ToolMapper toolMapper;
private final I18nService i18nService;
+ // ==================== Plugin Tools ====================
+
+ /** Plugin-registered tool entries with lazy availability checks */
+ private final CopyOnWriteArrayList pluginTools = new CopyOnWriteArrayList<>();
+
+ /** A tool entry registered by a plugin */
+ public record PluginToolEntry(ToolCallback callback, Supplier availabilityCheck) {}
+
+ /**
+ * Register a tool from a plugin with an availability check.
+ * The check is evaluated lazily each time the tool set is built.
+ */
+ public void registerPluginTool(ToolCallback callback, Supplier availabilityCheck) {
+ pluginTools.add(new PluginToolEntry(callback, availabilityCheck != null ? availabilityCheck : () -> true));
+ log.info("Plugin tool registered: {}", callback.getToolDefinition().name());
+ }
+
+ /**
+ * Unregister a plugin tool by name.
+ */
+ public void unregisterPluginTool(String toolName) {
+ pluginTools.removeIf(entry -> entry.callback().getToolDefinition().name().equals(toolName));
+ log.info("Plugin tool unregistered: {}", toolName);
+ }
+
/**
* 获取所有已启用的工具 Bean(Spring AI @Tool 注解方式)
* 通过数据库 enabled 标志过滤,确保 UI 开关真正生效
@@ -118,8 +145,25 @@ public class ToolRegistry {
}
}
- log.info("Building AgentToolSet: toolBeans={}, providers={}, totalCallbacks={}",
- toolBeans.size(), providers.size(), localizedCallbacks.size());
+ // Plugin tool callbacks — evaluate availability checks lazily
+ int pluginToolCount = 0;
+ for (PluginToolEntry entry : pluginTools) {
+ try {
+ if (Boolean.TRUE.equals(entry.availabilityCheck().get())) {
+ localizedCallbacks.add(entry.callback());
+ pluginToolCount++;
+ } else {
+ log.debug("Plugin tool excluded (availability check failed): {}",
+ entry.callback().getToolDefinition().name());
+ }
+ } catch (Exception e) {
+ log.warn("Plugin tool availability check failed for {}: {}",
+ entry.callback().getToolDefinition().name(), e.getMessage());
+ }
+ }
+
+ log.info("Building AgentToolSet: toolBeans={}, providers={}, pluginTools={}, totalCallbacks={}",
+ toolBeans.size(), providers.size(), pluginToolCount, localizedCallbacks.size());
return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks);
}
diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml
index 2ab8cc8a..3faaf4b1 100644
--- a/mateclaw-server/src/main/resources/application.yml
+++ b/mateclaw-server/src/main/resources/application.yml
@@ -108,6 +108,9 @@ mateclaw:
search-path: /api/v1/search
http-timeout: 15
http-retries: 3
+ plugin:
+ enabled: true
+ user-dir: ${user.home}/.mateclaw/plugins
# MateClaw Agent 配置
mate:
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql b/mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql
new file mode 100644
index 00000000..194b6f33
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql
@@ -0,0 +1,20 @@
+-- Plugin SDK: mate_plugin table
+CREATE TABLE IF NOT EXISTS mate_plugin (
+ id BIGINT NOT NULL PRIMARY KEY,
+ name VARCHAR(128) NOT NULL,
+ version VARCHAR(32) NOT NULL,
+ plugin_type VARCHAR(32) NOT NULL,
+ display_name VARCHAR(128),
+ description TEXT,
+ author VARCHAR(128),
+ entrypoint VARCHAR(256) NOT NULL,
+ jar_path VARCHAR(512),
+ config_json TEXT NOT NULL DEFAULT '{}',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ status VARCHAR(32) NOT NULL DEFAULT 'LOADED',
+ error_message TEXT,
+ create_time DATETIME NOT NULL,
+ update_time DATETIME NOT NULL,
+ deleted INT NOT NULL DEFAULT 0
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_name ON mate_plugin(name);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql
new file mode 100644
index 00000000..4b98c14e
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql
@@ -0,0 +1,3 @@
+-- V4: Add default_thinking_level to mate_agent
+-- Supports: off / low / medium / high / max (null = follow model default)
+ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS default_thinking_level VARCHAR(32) DEFAULT NULL;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql
new file mode 100644
index 00000000..8a0d82a4
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql
@@ -0,0 +1,3 @@
+-- V5: Add parent_conversation_id to mate_conversation for multi-agent delegation tracking
+ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS parent_conversation_id VARCHAR(64) DEFAULT NULL;
+CREATE INDEX idx_conversation_parent ON mate_conversation(parent_conversation_id);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql
new file mode 100644
index 00000000..ec5100c6
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql
@@ -0,0 +1,20 @@
+-- Plugin SDK: mate_plugin table
+CREATE TABLE IF NOT EXISTS mate_plugin (
+ id BIGINT NOT NULL PRIMARY KEY,
+ name VARCHAR(128) NOT NULL,
+ version VARCHAR(32) NOT NULL,
+ plugin_type VARCHAR(32) NOT NULL,
+ display_name VARCHAR(128),
+ description TEXT,
+ author VARCHAR(128),
+ entrypoint VARCHAR(256) NOT NULL,
+ jar_path VARCHAR(512),
+ config_json TEXT NOT NULL DEFAULT ('{}'),
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ status VARCHAR(32) NOT NULL DEFAULT 'LOADED',
+ error_message TEXT,
+ create_time DATETIME NOT NULL,
+ update_time DATETIME NOT NULL,
+ deleted INT NOT NULL DEFAULT 0,
+ UNIQUE KEY uk_plugin_name (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql
index 8e2db168..9f7a6316 100644
--- a/mateclaw-server/src/main/resources/db/schema.sql
+++ b/mateclaw-server/src/main/resources/db/schema.sql
@@ -642,3 +642,26 @@ CREATE TABLE IF NOT EXISTS mate_audit_event (
CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event(workspace_id, create_time);
CREATE INDEX IF NOT EXISTS idx_audit_user ON mate_audit_event(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_resource ON mate_audit_event(resource_type, resource_id);
+
+-- =============================================
+-- 插件表(Plugin SDK)
+-- =============================================
+CREATE TABLE IF NOT EXISTS mate_plugin (
+ id BIGINT NOT NULL PRIMARY KEY,
+ name VARCHAR(128) NOT NULL,
+ version VARCHAR(32) NOT NULL,
+ plugin_type VARCHAR(32) NOT NULL,
+ display_name VARCHAR(128),
+ description TEXT,
+ author VARCHAR(128),
+ entrypoint VARCHAR(256) NOT NULL,
+ jar_path VARCHAR(512),
+ config_json TEXT NOT NULL DEFAULT '{}',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ status VARCHAR(32) NOT NULL DEFAULT 'LOADED',
+ error_message TEXT,
+ create_time DATETIME NOT NULL,
+ update_time DATETIME NOT NULL,
+ deleted INT NOT NULL DEFAULT 0
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_name ON mate_plugin(name);
diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts
index 51ef5ec9..70d3875a 100644
--- a/mateclaw-ui/src/api/index.ts
+++ b/mateclaw-ui/src/api/index.ts
@@ -433,6 +433,15 @@ export const dashboardApi = {
recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }),
}
+// ==================== Plugins ====================
+export const pluginApi = {
+ list: () => http.get('/plugins'),
+ get: (name: string) => http.get(`/plugins/${name}`),
+ disable: (name: string) => http.post(`/plugins/${name}/disable`),
+ enable: (name: string) => http.post(`/plugins/${name}/enable`),
+ updateConfig: (name: string, config: Record) => http.put(`/plugins/${name}/config`, config),
+}
+
// ==================== Audit Events ====================
export const auditApi = {
listEvents: (params: {
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index ee86db89..12b84e6c 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -207,6 +207,7 @@ export default {
skills: 'Skills',
wiki: 'Wiki KB',
tools: 'Tools',
+ plugins: 'Plugins',
core: 'Core',
connect: 'Connect',
system: 'System',
@@ -1064,6 +1065,26 @@ export default {
toggleFailed: 'Failed to toggle tool status',
},
},
+ plugins: {
+ title: 'Plugins',
+ desc: 'Manage external plugins loaded from JAR files',
+ refresh: 'Refresh',
+ type: 'Type',
+ author: 'Author',
+ status: 'Status',
+ tools: 'Tools',
+ channels: 'Channels',
+ provider: 'Provider',
+ memoryProvider: 'Memory',
+ noDescription: 'No description',
+ emptyTitle: 'No plugins installed',
+ emptyHint: 'Place plugin JAR files in ~/.mateclaw/plugins/ and restart the server',
+ loading: 'Loading plugins...',
+ loadFailed: 'Failed to load plugins',
+ enabled: '{name} enabled',
+ disabled: '{name} disabled',
+ toggleFailed: 'Failed to toggle plugin status',
+ },
datasources: {
kicker: 'Data Fabric',
title: 'Datasources',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 151300ec..5c957f89 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -210,6 +210,7 @@ export default {
skills: '技能',
wiki: 'Wiki 知识库',
tools: '工具',
+ plugins: '插件',
datasources: '数据源',
mcpServers: 'MCP 服务',
settingsGroup: '设置',
@@ -1064,6 +1065,26 @@ export default {
toggleFailed: '切换工具状态失败',
},
},
+ plugins: {
+ title: '插件',
+ desc: '管理从 JAR 文件加载的外部插件',
+ refresh: '刷新',
+ type: '类型',
+ author: '作者',
+ status: '状态',
+ tools: '工具',
+ channels: '渠道',
+ provider: 'Provider',
+ memoryProvider: '记忆',
+ noDescription: '暂无描述',
+ emptyTitle: '暂无已安装插件',
+ emptyHint: '将插件 JAR 文件放入 ~/.mateclaw/plugins/ 目录后重启服务',
+ loading: '加载插件中...',
+ loadFailed: '加载插件失败',
+ enabled: '{name} 已启用',
+ disabled: '{name} 已禁用',
+ toggleFailed: '切换插件状态失败',
+ },
datasources: {
kicker: '数据接入',
title: '数据源管理',
diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts
index e5cade1a..f5b93e4d 100644
--- a/mateclaw-ui/src/router/index.ts
+++ b/mateclaw-ui/src/router/index.ts
@@ -52,6 +52,12 @@ const router = createRouter({
component: () => import('@/views/Tools.vue'),
meta: { title: 'Tools' },
},
+ {
+ path: 'plugins',
+ name: 'Plugins',
+ component: () => import('@/views/Plugins.vue'),
+ meta: { title: 'Plugins' },
+ },
// ==================== Settings (absorbs advanced pages) ====================
{
path: 'settings',
diff --git a/mateclaw-ui/src/views/Plugins.vue b/mateclaw-ui/src/views/Plugins.vue
new file mode 100644
index 00000000..b30ed3f3
--- /dev/null
+++ b/mateclaw-ui/src/views/Plugins.vue
@@ -0,0 +1,410 @@
+
+
+
+
+
+
+
+
+
+
{{ t('plugins.loading') }}
+
+
+
+
+
+
+
+
{{ plugin.description || t('plugins.noDescription') }}
+
+
+
+ {{ t('plugins.type') }}
+ {{ plugin.type }}
+
+
+ {{ t('plugins.author') }}
+ {{ plugin.author }}
+
+
+ {{ t('plugins.status') }}
+
+ {{ plugin.status }}
+
+
+
+
+
+
+
+ {{ t('plugins.tools') }}:
+ {{ tool }}
+
+
+ {{ t('plugins.channels') }}:
+ {{ ch }}
+
+
+ {{ t('plugins.provider') }}:
+ {{ plugin.registeredProvider }}
+
+
+ {{ t('plugins.memoryProvider') }}:
+ {{ plugin.registeredMemoryProvider }}
+
+
+
+
+
+
+
+
+
+
+ {{ plugin.errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('plugins.emptyTitle') }}
+
{{ t('plugins.emptyHint') }}
+
+
+
+
+
+
+
+
+
diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue
index e1a4e27e..72094f74 100644
--- a/mateclaw-ui/src/views/layout/MainLayout.vue
+++ b/mateclaw-ui/src/views/layout/MainLayout.vue
@@ -321,6 +321,11 @@ const navGroups = computed(() => [
label: t('nav.tools'),
icon: ` `,
},
+ {
+ path: '/plugins',
+ label: t('nav.plugins'),
+ icon: ` `,
+ },
],
},
{