-
- aliyun-first
-
-
- aliyun-public-first
- https://maven.aliyun.com/repository/public
- true
- false
-
-
- aliyun-spring-first
- https://maven.aliyun.com/repository/spring
- true
- false
-
-
-
-
- aliyun-public-first
- https://maven.aliyun.com/repository/public
- true
- false
-
-
-
-
) so that
- * future Dream runs do not overwrite user modifications.
+ * When a user edits a memory entry, this service writes it back to the target
+ * memory file (MEMORY.md, PROFILE.md, SOUL.md, ...) with a hidden metadata
+ * marker ({@code }) so that future Dream runs
+ * do not overwrite user modifications.
*
* @author MateClaw Team
*/
@@ -24,53 +26,64 @@ import java.time.LocalDate;
@RequiredArgsConstructor
public class MemoryHilService {
+ /** Matches a whole-line user-edited marker so repeated edits do not accumulate markers. */
+ private static final Pattern USER_EDITED_MARKER =
+ Pattern.compile("(?m)^[ \\t]*[ \\t]*\\r?\\n?");
+
private final WorkspaceFileService workspaceFileService;
private final ApplicationEventPublisher eventPublisher;
/**
- * Edit a section in MEMORY.md identified by key (section heading).
+ * Edit a section identified by key (section heading) inside {@code filename}.
* Appends user-edited metadata so Dream prompts respect user changes.
+ *
+ * @param agentId the agent whose workspace file is edited
+ * @param filename the target memory file (e.g. MEMORY.md / PROFILE.md / SOUL.md)
+ * @param key the section heading (text after {@code ## })
+ * @param newContent the new section body
*/
- public void editMemoryEntry(Long agentId, String key, String newContent) {
- WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md");
- if (file == null || file.getContent() == null) {
- log.warn("[HiL] MEMORY.md not found for agent={}", agentId);
- return;
- }
+ public void editMemoryEntry(Long agentId, String filename, String key, String newContent) {
+ WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
+ String fileContent = (file != null && file.getContent() != null) ? file.getContent() : "";
- String memoryContent = file.getContent();
+ // Strip any pre-existing user-edited markers from the incoming body so a
+ // section edited multiple times does not pick up a stack of markers.
+ String cleanContent = USER_EDITED_MARKER.matcher(newContent).replaceAll("").trim();
+ String metadata = "";
String sectionHeader = "## " + key;
- int headerIdx = memoryContent.indexOf(sectionHeader);
+ int headerIdx = fileContent.indexOf(sectionHeader);
+ String updated;
if (headerIdx < 0) {
- // Section not found — append as new section
- String metadata = "";
- String newSection = "\n\n" + sectionHeader + "\n" + newContent.trim() + "\n" + metadata;
- memoryContent = memoryContent.trim() + newSection;
+ // Section not found — append as a new section.
+ String newSection = sectionHeader + "\n" + cleanContent + "\n" + metadata;
+ updated = fileContent.isBlank() ? newSection : fileContent.trim() + "\n\n" + newSection;
} else {
- // Find section boundaries
- int contentStart = memoryContent.indexOf('\n', headerIdx) + 1;
- int nextSection = memoryContent.indexOf("\n## ", contentStart);
- int sectionEnd = nextSection > 0 ? nextSection : memoryContent.length();
-
- // Replace section content
- String metadata = "";
- String replacement = newContent.trim() + "\n" + metadata + "\n";
- memoryContent = memoryContent.substring(0, contentStart) + replacement
- + memoryContent.substring(sectionEnd);
+ // Replace the existing section body, keeping the heading in place.
+ int contentStart = fileContent.indexOf('\n', headerIdx) + 1;
+ int nextSection = fileContent.indexOf("\n## ", contentStart);
+ int sectionEnd = nextSection > 0 ? nextSection : fileContent.length();
+ String replacement = cleanContent + "\n" + metadata + "\n";
+ updated = fileContent.substring(0, contentStart) + replacement
+ + fileContent.substring(sectionEnd);
}
- workspaceFileService.saveFile(agentId, "MEMORY.md", memoryContent);
- eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "user-edit", newContent));
- log.info("[HiL] User edited MEMORY.md section '{}' for agent={}", key, agentId);
+ workspaceFileService.saveFile(agentId, filename, updated);
+ // SOUL.md auto-evolution counts canonical memory writes. A manual SOUL.md
+ // edit must not bump that counter, or a later auto-regeneration would
+ // discard the user's edit; PROFILE.md likewise is not a write trigger.
+ if ("MEMORY.md".equals(filename)) {
+ eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "user-edit", cleanContent));
+ }
+ log.info("[HiL] User edited {} section '{}' for agent={}", filename, key, agentId);
}
/**
- * Check if a section heading exists in MEMORY.md.
- * Used by DreamController to validate edit key before allowing write.
+ * Check if a section heading exists in {@code filename}.
+ * Used by DreamController to validate the edit key before allowing a write.
*/
- public boolean sectionExists(Long agentId, String key) {
- WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md");
+ public boolean sectionExists(Long agentId, String filename, String key) {
+ WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
if (file == null || file.getContent() == null) return false;
return file.getContent().contains("## " + key);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
index af8b3328..4af0007b 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
@@ -13,8 +13,6 @@ import java.util.List;
* Post-turn sync (async persistence)
* Agent tools (Spring AI @Tool beans)
*
- *
- * Inspired by Hermes Agent's MemoryProvider architecture.
*
* @author MateClaw Team
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java
new file mode 100644
index 00000000..1f1396ae
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java
@@ -0,0 +1,72 @@
+package vip.mate.notification;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import vip.mate.agent.runtime.AgentRuntimeAggregator;
+import vip.mate.approval.ApprovalWorkflowService;
+import vip.mate.common.result.R;
+import vip.mate.exception.MateClawException;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Aggregate counts that drive global UI attention signals (sidebar badges,
+ * future notification center).
+ *
+ *
Designed so the frontend can poll a single endpoint instead of fan-out
+ * to every domain service. Fields with no settled "is it actually a problem"
+ * semantics (failed crons / down channels / down MCP servers) are returned
+ * as zero placeholders so the wire shape is stable and later phases can
+ * populate them without bumping the contract.
+ */
+@Slf4j
+@Tag(name = "Notifications")
+@RestController
+@RequestMapping("/api/v1/notifications")
+@RequiredArgsConstructor
+public class NotificationController {
+
+ private final ApprovalWorkflowService approvalWorkflowService;
+ private final AgentRuntimeAggregator agentRuntimeAggregator;
+
+ @Operation(summary = "Aggregated counts for the sidebar attention badges")
+ @GetMapping("/summary")
+ public R> summary(Authentication auth) {
+ boolean admin = isAdmin(auth);
+
+ // Cast to int — counts won't exceed Integer.MAX_VALUE in practice
+ // and the project's global Jackson config serializes Long as a string
+ // (for ID precision), which would break the numeric UI badge.
+ int pendingApprovals = (int) Math.min(Integer.MAX_VALUE, approvalWorkflowService.countPendingFromDb());
+ int stuckAgents = admin
+ ? agentRuntimeAggregator.snapshot().summary().stuck()
+ : 0;
+
+ Map payload = new LinkedHashMap<>();
+ payload.put("pendingApprovals", pendingApprovals);
+ payload.put("stuckAgents", stuckAgents);
+ // Reserved fields — wire shape stays stable so the frontend doesn't
+ // need a fan-out when these get real semantics later.
+ payload.put("failedCrons", 0);
+ payload.put("downChannels", 0);
+ payload.put("downMcps", 0);
+ return R.ok(payload);
+ }
+
+ private boolean isAdmin(Authentication auth) {
+ if (auth == null) {
+ throw new MateClawException(401, "authentication required");
+ }
+ return auth.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .anyMatch("ROLE_ADMIN"::equals);
+ }
+}
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
index dd24ca49..3ccdfb46 100644
--- a/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.plugin.PluginManager;
import vip.mate.plugin.model.PluginInfo;
+import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
import java.util.Map;
@@ -26,18 +27,21 @@ public class PluginController {
@Operation(summary = "List all plugins")
@GetMapping
+ @RequireWorkspaceRole("admin")
public R> list() {
return R.ok(pluginManager.listPlugins());
}
@Operation(summary = "Get plugin detail")
@GetMapping("/{name}")
+ @RequireWorkspaceRole("admin")
public R get(@PathVariable String name) {
return R.ok(pluginManager.getPlugin(name));
}
@Operation(summary = "Disable a plugin")
@PostMapping("/{name}/disable")
+ @RequireWorkspaceRole("admin")
public R disable(@PathVariable String name) {
pluginManager.disablePlugin(name);
return R.ok();
@@ -45,6 +49,7 @@ public class PluginController {
@Operation(summary = "Enable a plugin")
@PostMapping("/{name}/enable")
+ @RequireWorkspaceRole("admin")
public R enable(@PathVariable String name) {
pluginManager.enablePlugin(name);
return R.ok();
@@ -52,6 +57,7 @@ public class PluginController {
@Operation(summary = "Update plugin configuration")
@PutMapping("/{name}/config")
+ @RequireWorkspaceRole("admin")
public R updateConfig(@PathVariable String name,
@RequestBody Map config) {
pluginManager.updateConfig(name, config);
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
index e5ae7a20..448289e9 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
@@ -58,23 +58,36 @@ import java.util.concurrent.ConcurrentHashMap;
* page always shows current state.
*
*
- * ID namespace: virtual skill ids use a high sentinel
- * {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two
- * id spaces never collide and a callsite can dispatch on which bridge
- * owns an id without coordination.
+ *
ID namespace: virtual ACP ids set both top bits of a {@code long}
+ * (bit 63 + bit 62) so they sit in a different type-tag than MCP
+ * (which sets only bit 63 — see
+ * {@link vip.mate.skill.mcp.McpSkillBridge}). The bottom 62 bits carry
+ * the underlying endpointId. The earlier {@code 8e18 + endpointId}
+ * addition scheme broke once Snowflake-issued endpoint ids crossed
+ * the {@code 1e17} bound, so the bit-tagged layout replaces it.
+ *
+ *
{@code VIRTUAL_ID_BASE + smallId} still equals
+ * {@code VIRTUAL_ID_BASE | smallId} for any {@code smallId < 2^62}, so
+ * test fixtures that build virtual ids by addition continue to work.
*/
@Slf4j
@Service
public class AcpSkillBridge {
+ /** Type tag for ACP virtual ids: bits 63 + 62 set. */
+ public static final long VIRTUAL_ID_BASE = 0xC000000000000000L;
/**
- * High sentinel for ACP virtual id space. Distinct from
- * {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual
- * spaces are partitionable by simple range checks.
+ * @deprecated The bound is implicit in the bit-tag layout — any id
+ * whose top two bits are both set is an ACP virtual id. Kept
+ * for source compatibility with earlier callers.
*/
- public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L;
- /** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */
- public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L;
+ @Deprecated
+ public static final long VIRTUAL_ID_BOUND = -1L; // 0xFFFFFFFFFFFFFFFFL
+
+ /** Selects the top-two type-tag bits. */
+ private static final long TAG_MASK = 0xC000000000000000L;
+ /** Selects the bottom 62 bits that carry the original endpoint id. */
+ private static final long ID_MASK = 0x3FFFFFFFFFFFFFFFL;
private final AcpEndpointService endpointService;
private final AcpDelegationService delegationService;
@@ -100,16 +113,22 @@ public class AcpSkillBridge {
}
public static boolean isVirtualAcpSkillId(Long id) {
- return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND;
+ return id != null && (id & TAG_MASK) == VIRTUAL_ID_BASE;
}
public static Long extractEndpointId(Long virtualId) {
if (!isVirtualAcpSkillId(virtualId)) return null;
- return virtualId - VIRTUAL_ID_BASE;
+ return virtualId & ID_MASK;
}
public static long virtualIdFor(AcpEndpointEntity endpoint) {
- return VIRTUAL_ID_BASE + endpoint.getId();
+ long eid = endpoint.getId();
+ if ((eid & TAG_MASK) != 0L) {
+ throw new IllegalStateException(
+ "ACP endpoint id 0x" + Long.toHexString(eid)
+ + " uses the top two bits — would collide with the virtual id type tag");
+ }
+ return VIRTUAL_ID_BASE | eid;
}
@PostConstruct
@@ -212,7 +231,7 @@ public class AcpSkillBridge {
private void registerWrappers(AcpEndpointEntity ep) {
if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return;
- String slug = slugify(ep.getName());
+ String slug = slugForEndpoint(ep);
if (slug.isEmpty()) {
log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId());
return;
@@ -289,7 +308,7 @@ public class AcpSkillBridge {
private SkillEntity endpointToEntity(AcpEndpointEntity ep) {
SkillEntity s = new SkillEntity();
s.setId(virtualIdFor(ep));
- s.setName(slugify(ep.getName()));
+ s.setName(slugForEndpoint(ep));
s.setNameEn(displayName(ep));
s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank()
? displayName(ep) : null);
@@ -310,6 +329,7 @@ public class AcpSkillBridge {
s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts
s.setConfigJson(buildConfigJson(ep));
s.setManifestJson(serializeManifest(buildManifest(ep)));
+ s.setSkillContent(buildSkillContent(ep));
return s;
}
@@ -342,9 +362,9 @@ public class AcpSkillBridge {
return ResolvedSkill.builder()
.id(virtualIdFor(ep))
- .name(slugify(ep.getName()))
+ .name(slugForEndpoint(ep))
.description(buildDescription(ep))
- .content("") // no SKILL.md
+ .content(buildSkillContent(ep))
.source("acp")
.skillDir(null)
.configuredSkillDir(null)
@@ -374,7 +394,7 @@ public class AcpSkillBridge {
* it up the same way as a hand-authored skill manifest.
*/
private SkillManifest buildManifest(AcpEndpointEntity ep) {
- String slug = slugify(ep.getName());
+ String slug = slugForEndpoint(ep);
String toolName = "acp_" + slug + "_prompt";
List tools = List.of(toolName);
@@ -423,6 +443,72 @@ public class AcpSkillBridge {
.build();
}
+ /**
+ * Synthesize a SKILL.md body for an ACP-derived virtual skill.
+ *
+ * ACP endpoints carry no hand-authored SKILL.md — they wrap an
+ * external coding-agent CLI rather than a skill package. Without a
+ * synthesized body, an agent that calls
+ * {@code readSkillFile(skillName=..., filePath="SKILL.md")} gets
+ * nothing beyond the one-line description and cannot tell how to
+ * drive the endpoint.
+ *
+ *
This builds a markdown brief from the live endpoint row: what
+ * the endpoint is, the single wrapper tool it exposes, that tool's
+ * arguments, and usage notes — so the LLM can call
+ * {@code acp__prompt} correctly on the first attempt.
+ */
+ private String buildSkillContent(AcpEndpointEntity ep) {
+ String slug = slugForEndpoint(ep);
+ String toolName = "acp_" + slug + "_prompt";
+ StringBuilder sb = new StringBuilder();
+
+ sb.append("# ").append(displayName(ep)).append("\n\n");
+ sb.append(buildDescription(ep)).append("\n\n");
+
+ sb.append("## Overview\n\n");
+ sb.append("This skill delegates work to the **").append(ep.getName())
+ .append("** ACP (Agent Communication Protocol) coding agent. ")
+ .append("The agent runs as an external CLI process spawned on demand: ")
+ .append("send it a single natural-language instruction and it returns ")
+ .append("its final reply.\n\n");
+
+ sb.append("## Tools\n\n");
+ sb.append("### `").append(toolName).append("`\n\n");
+ sb.append("Delegate a prompt to the '").append(ep.getName())
+ .append("' coding agent and receive its final reply.\n\n");
+ sb.append("Parameters:\n\n");
+ sb.append("- `prompt` (string, required) — the instruction or question to send.\n");
+ sb.append("- `cwd` (string, optional) — working directory; defaults to the ")
+ .append("endpoint's workspace base path when omitted.\n\n");
+
+ sb.append("## Usage notes\n\n");
+ sb.append("- Call `").append(toolName).append("` with one self-contained instruction. ")
+ .append("The endpoint runs autonomously and returns only its final answer, ")
+ .append("not intermediate steps.\n");
+ sb.append("- Omit `cwd` unless the task needs a specific directory — the server ")
+ .append("resolves the endpoint's bound workspace path.\n");
+ if (Boolean.TRUE.equals(ep.getTrusted())) {
+ sb.append("- This endpoint is trusted: the agent's own tool calls are accepted ")
+ .append("without re-prompting for approval.\n");
+ } else {
+ sb.append("- This endpoint is not trusted: the agent's tool calls may require ")
+ .append("human approval before they run.\n");
+ }
+ String status = nullSafe(ep.getLastStatus());
+ if ("OK".equalsIgnoreCase(status)) {
+ sb.append("- Last connection test: OK.\n");
+ } else if ("ERROR".equalsIgnoreCase(status)
+ || (ep.getLastError() != null && !ep.getLastError().isBlank())) {
+ sb.append("- Last connection test failed: ").append(nullSafe(ep.getLastError()))
+ .append(". The CLI may not be installed or reachable.\n");
+ } else {
+ sb.append("- Not yet tested — the CLI is spawned on the first call.\n");
+ }
+
+ return sb.toString();
+ }
+
// ==================== Helpers ====================
private List safeListEnabled() {
@@ -447,6 +533,28 @@ public class AcpSkillBridge {
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
}
+ /**
+ * Stable slug for an ACP endpoint. Falls back to {@code acp-{id}} when
+ * the source name has no ASCII letter/digit (e.g. pure CJK), because
+ * the naive slugify would otherwise return a run of dashes and two
+ * differently-named all-CJK endpoints would collide on the same slug,
+ * which is also the basis for the {@code acp__prompt} wrapper
+ * tool name registered in the global tool registry.
+ */
+ private String slugForEndpoint(AcpEndpointEntity ep) {
+ String slug = slugify(ep.getName());
+ return hasAsciiAlphaNumeric(slug) ? slug : "acp-" + ep.getId();
+ }
+
+ private static boolean hasAsciiAlphaNumeric(String s) {
+ if (s == null || s.isEmpty()) return false;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) return true;
+ }
+ return false;
+ }
+
private String displayName(AcpEndpointEntity ep) {
if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName();
return ep.getName() != null ? ep.getName() : "acp-" + ep.getId();
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
index d40d957d..ce3c0243 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentSkillBinding;
+import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.service.AgentBindingService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -25,7 +26,15 @@ import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.BundledSkillSyncer;
import vip.mate.skill.workspace.SkillFileSyncer;
import vip.mate.skill.workspace.SkillWorkspaceManager;
+import vip.mate.exception.MateClawException;
+import vip.mate.skill.lifecycle.ConfirmRequiredException;
+import vip.mate.skill.lifecycle.LifecycleTransition;
+import vip.mate.skill.lifecycle.SkillCuratorJob;
+import vip.mate.skill.lifecycle.SkillCuratorReport;
+import vip.mate.skill.lifecycle.SkillCuratorReportStore;
+import vip.mate.skill.lifecycle.SkillLifecycleService;
+import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -59,10 +68,15 @@ public class SkillController {
private final AgentBindingService agentBindingService;
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge;
+ private final SkillLifecycleService skillLifecycleService;
+ private final SkillCuratorJob skillCuratorJob;
+ private final SkillCuratorReportStore skillCuratorReportStore;
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
@GetMapping
+ @RequireWorkspaceRole("member")
public R> list(
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@@ -72,13 +86,20 @@ public class SkillController {
@RequestParam(required = false) String sort,
@RequestParam(required = false) String source,
@RequestParam(required = false) String runtime,
+ @RequestParam(required = false) String lifecycleState,
@RequestParam(required = false) Long agentId) {
Set pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
IPage dbPage = skillService.pageSkills(
- page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, pinnedSkillIds);
- List virtualSkills = visibleVirtualSkills(
- keyword, skillType, enabled, scanStatus, sort, source, runtime);
+ page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime,
+ pinnedSkillIds, workspaceId, lifecycleState);
+ // Virtual MCP/ACP skills mirror live servers and carry no lifecycle
+ // state — exclude them whenever the caller filters by lifecycleState
+ // (stale / archived / active), otherwise they leak into every tab.
+ List virtualSkills = (lifecycleState != null && !lifecycleState.isBlank())
+ ? List.of()
+ : visibleVirtualSkills(
+ workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime);
if (!virtualSkills.isEmpty()) {
VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
@@ -90,9 +111,11 @@ public class SkillController {
@Operation(summary = "获取各类型技能计数(tab 徽章用)")
@GetMapping("/counts")
- public R> counts() {
- Map result = skillService.countByType();
- Set realNames = realSkillNames();
+ @RequireWorkspaceRole("member")
+ public R> counts(
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ Map result = skillService.countByType(workspaceId);
+ Set realNames = realSkillNames(workspaceId);
// RFC-090 §3.2 — virtual MCP-derived skills aren't in mate_skill,
// so countByType() misses them. Fold in the live count so the
// "MCP" and "all" tab badges match what the list endpoint shows.
@@ -139,6 +162,14 @@ public class SkillController {
return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size();
}
+ /** Keep only enabled rows — gates virtual skills into enabled-only endpoints. */
+ static List enabledOnly(List skills) {
+ if (skills == null || skills.isEmpty()) return List.of();
+ return skills.stream()
+ .filter(s -> s != null && Boolean.TRUE.equals(s.getEnabled()))
+ .toList();
+ }
+
/**
* Keep MyBatis-Plus as the source of truth for DB pagination and append
* live virtual ACP/MCP rows after the DB rows. This produces one stable
@@ -182,7 +213,8 @@ public class SkillController {
record VirtualPageMergeResult(List records, long total) {}
- private List visibleVirtualSkills(String keyword,
+ private List visibleVirtualSkills(Long workspaceId,
+ String keyword,
String skillType,
Boolean enabled,
String scanStatus,
@@ -194,7 +226,7 @@ public class SkillController {
boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource);
if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of();
- Set realNames = realSkillNames();
+ Set realNames = realSkillNames(workspaceId);
List result = new ArrayList<>();
if (includeMcpVirtuals) {
try {
@@ -242,16 +274,25 @@ public class SkillController {
return value != null && value.toLowerCase().contains(lowerCaseNeedle);
}
- private Set realSkillNames() {
- return skillService.listSkills().stream()
+ /**
+ * Names of every real {@code mate_skill} row visible in {@code
+ * workspaceId} (builtin + workspace-owned). Used to shadow same-named
+ * MCP/ACP virtual skills so the catalog never shows two cards for one
+ * capability.
+ */
+ private Set realSkillNames(Long workspaceId) {
+ return skillService.listSkills(workspaceId).stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)")
@PostMapping("/{id}/rescan")
- public R rescan(@PathVariable Long id) {
+ @RequireWorkspaceRole("admin")
+ public R rescan(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
+ verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.rescanSecurity(id));
}
@@ -260,9 +301,12 @@ public class SkillController {
"in a multi-instance deployment. Pulls every mate_skill_file row owned by the skill " +
"down to disk; if no rows exist yet but local files do, ingests them into the canonical store.")
@PostMapping("/{id}/sync-files")
- public R> syncFiles(@PathVariable Long id) {
+ @RequireWorkspaceRole("admin")
+ public R> syncFiles(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
+ verifyResourceWorkspace(skill, workspaceId);
var report = skillFileSyncer.syncOne(skill);
Map body = new LinkedHashMap<>();
body.put("skillId", id);
@@ -278,6 +322,7 @@ public class SkillController {
description = "Bulk variant of /sync-files; primarily for ops debugging when you suspect " +
"the local workspace is out of sync with the canonical store.")
@PostMapping("/sync-files")
+ @RequireWorkspaceRole("admin")
public R> syncAllFiles() {
var report = skillFileSyncer.syncAll();
Map body = new LinkedHashMap<>();
@@ -308,27 +353,54 @@ public class SkillController {
}
}
+ /**
+ * Reject access to a skill that the request's workspace doesn't own.
+ * Builtin skills are global and exempt — every workspace may read and
+ * (where role permits) toggle them. The interceptor already verified
+ * the caller's role inside {@code workspaceId}; this guard closes the
+ * remaining gap where a member of workspace B targets a skill id that
+ * actually belongs to workspace A.
+ */
+ private void verifyResourceWorkspace(SkillEntity skill, Long headerWorkspaceId) {
+ if (skill == null || Boolean.TRUE.equals(skill.getBuiltin())) {
+ return;
+ }
+ long requested = headerWorkspaceId != null
+ ? headerWorkspaceId : SkillService.DEFAULT_WORKSPACE_ID;
+ long owner = skill.getWorkspaceId() != null
+ ? skill.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID;
+ if (owner != requested) {
+ throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403,
+ "Skill " + skill.getId() + " does not belong to the current workspace");
+ }
+ }
+
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
- public R> listEnabled() {
+ @RequireWorkspaceRole("member")
+ public R> listEnabled(
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// Mirror the merging the paginated /skills endpoint does so the agent
// edit picker (which calls this endpoint) sees MCP- and ACP-derived
// virtual skills alongside the persisted ones. The shadow base must
// include all real skill names — including disabled ones — so a
// disabled real skill correctly suppresses its same-named virtual
// twin, matching /skills and /counts.
- List result = new ArrayList<>(skillService.listEnabledSkills());
- Set realNames = realSkillNames();
+ // The bridges surface disabled MCP/ACP servers too (so the Skills
+ // page can show a toggled-off card); this endpoint is enabled-only,
+ // so the virtual rows are filtered to enabled before merging.
+ List result = new ArrayList<>(skillService.listEnabledSkills(workspaceId));
+ Set realNames = realSkillNames(workspaceId);
try {
- result.addAll(filterShadowedVirtualSkills(
- mcpSkillBridge.listMcpDerivedSkillEntities(), realNames));
+ result.addAll(enabledOnly(filterShadowedVirtualSkills(
+ mcpSkillBridge.listMcpDerivedSkillEntities(), realNames)));
} catch (Exception e) {
// Bridge failure must not 500 the picker — same defensive stance as /counts.
}
try {
- result.addAll(filterShadowedVirtualSkills(
- acpSkillBridge.listAcpDerivedSkillEntities(), realNames));
+ result.addAll(enabledOnly(filterShadowedVirtualSkills(
+ acpSkillBridge.listAcpDerivedSkillEntities(), realNames)));
} catch (Exception e) {
// Bridge failure must not 500 the picker — same defensive stance as /counts.
}
@@ -337,19 +409,25 @@ public class SkillController {
@Operation(summary = "按类型获取技能列表")
@GetMapping("/type/{skillType}")
- public R> listByType(@PathVariable String skillType) {
- return R.ok(skillService.listSkillsByType(skillType));
+ @RequireWorkspaceRole("member")
+ public R> listByType(@PathVariable String skillType,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ return R.ok(skillService.listSkillsByType(skillType, workspaceId));
}
@Operation(summary = "获取已启用技能摘要(按类型分组)")
@GetMapping("/summary")
- public R>> summary() {
- return R.ok(skillService.getEnabledSkillSummary());
+ @RequireWorkspaceRole("member")
+ public R>> summary(
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ return R.ok(skillService.getEnabledSkillSummary(workspaceId));
}
@Operation(summary = "获取技能详情")
@GetMapping("/{id}")
- public R get(@PathVariable Long id) {
+ @RequireWorkspaceRole("member")
+ public R get(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// RFC-090 §3.2 — virtual MCP-derived skills synthesize a row
// on demand from the live MCP server entity.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
@@ -365,19 +443,31 @@ public class SkillController {
SkillEntity ent = acpSkillBridge.findEntityById(id);
return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id);
}
- return R.ok(skillService.getSkill(id));
+ SkillEntity skill = skillService.getSkill(id);
+ verifyResourceWorkspace(skill, workspaceId);
+ return R.ok(skill);
}
@Operation(summary = "创建技能")
@PostMapping
- public R create(@RequestBody SkillEntity skill) {
+ @RequireWorkspaceRole("admin")
+ public R create(
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
+ @RequestBody SkillEntity skill) {
+ // Always stamp the owning workspace from the request context — never
+ // trust a workspaceId in the request body.
+ skill.setWorkspaceId(workspaceId != null
+ ? workspaceId : SkillService.DEFAULT_WORKSPACE_ID);
return R.ok(skillService.createSkill(skill));
}
@Operation(summary = "更新技能")
@PutMapping("/{id}")
- public R update(@PathVariable Long id, @RequestBody SkillEntity skill) {
+ @RequireWorkspaceRole("admin")
+ public R update(@PathVariable Long id, @RequestBody SkillEntity skill,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
+ verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skill.setId(id);
return R.ok(skillService.updateSkill(skill));
}
@@ -393,21 +483,35 @@ public class SkillController {
*/
@Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)")
@DeleteMapping("/{id}")
- public R delete(@PathVariable Long id) {
+ @RequireWorkspaceRole("admin")
+ public R delete(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
+ verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skillService.hardDeleteSkill(id);
return R.ok();
}
@Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle")
- public R toggle(@PathVariable Long id, @RequestParam boolean enabled) {
+ @RequireWorkspaceRole("admin")
+ public R toggle(@PathVariable Long id, @RequestParam boolean enabled,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ // A virtual MCP skill mirrors an MCP server — toggling it enables /
+ // disables that server, keeping the Skills page and Settings ▸ MCP
+ // Connections in sync. ACP virtual skills have no such mapping and
+ // stay read-only via rejectVirtualSkillMutation below.
+ if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
+ return R.ok(mcpSkillBridge.toggleVirtualSkill(id, enabled));
+ }
rejectVirtualSkillMutation(id);
+ verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.toggleSkill(id, enabled));
}
@Operation(summary = "预览技能 Prompt 增强效果(调试用,与 Agent 真实运行时一致)")
@GetMapping("/prompt-preview")
+ @RequireWorkspaceRole("admin")
public R> promptPreview() {
String prompt = skillRuntimeService.buildSkillPromptEnhancement();
return R.ok(Map.of(
@@ -421,6 +525,7 @@ public class SkillController {
@Operation(summary = "获取 active skills 运行时视图")
@GetMapping("/runtime/active")
+ @RequireWorkspaceRole("admin")
public R> getActiveSkills() {
List skills = skillRuntimeService.getActiveSkills();
return R.ok(Map.of("count", skills.size(), "skills", skills));
@@ -428,12 +533,14 @@ public class SkillController {
@Operation(summary = "获取所有技能的运行时解析状态(管理页面使用)")
@GetMapping("/runtime/status")
+ @RequireWorkspaceRole("admin")
public R> getRuntimeStatus() {
return R.ok(skillRuntimeService.resolveAllSkillsStatus());
}
@Operation(summary = "刷新 active skills 缓存,resync=true 时同步内置技能到 workspace")
@PostMapping("/runtime/refresh")
+ @RequireWorkspaceRole("admin")
public R> refreshRuntime(
@RequestParam(defaultValue = "false") boolean resync) {
List resynced = List.of();
@@ -459,6 +566,7 @@ public class SkillController {
*/
@Operation(summary = "Pre-flight requirement statuses for a skill (RFC-090)")
@GetMapping("/{id}/requirements")
+ @RequireWorkspaceRole("member")
public R> requirements(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
@@ -524,6 +632,7 @@ public class SkillController {
*/
@Operation(summary = "List agents that can use this skill (RFC-090 §14.2)")
@GetMapping("/{id}/employees")
+ @RequireWorkspaceRole("member")
public R>> employees(@PathVariable Long id) {
// Explicit bindings: agent_skill rows pointing to this skill.
List explicitBindings = agentSkillBindingMapper.selectList(
@@ -590,6 +699,7 @@ public class SkillController {
*/
@Operation(summary = "Read per-skill LESSONS.md (RFC-090 §11.4)")
@GetMapping("/{id}/lessons")
+ @RequireWorkspaceRole("member")
public R> getLessons(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
@@ -620,6 +730,7 @@ public class SkillController {
@Operation(summary = "Clear all lessons for a skill (RFC-090 §11.4)")
@PostMapping("/{id}/lessons/clear")
+ @RequireWorkspaceRole("admin")
public R> clearLessons(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
@@ -634,14 +745,17 @@ public class SkillController {
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
@PostMapping("/synthesize-from-conversation")
- public R> synthesizeFromConversation(@RequestBody Map body) {
+ @RequireWorkspaceRole("admin")
+ public R> synthesizeFromConversation(@RequestBody Map body,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
String conversationId = (String) body.get("conversationId");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
if (conversationId == null || conversationId.isBlank()) {
return R.fail("conversationId is required");
}
- SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId);
+ SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(
+ conversationId, agentId, workspaceId);
if (result.blocked()) {
return R.ok(Map.of(
@@ -666,8 +780,11 @@ public class SkillController {
@Operation(summary = "将 skill 导出到工作区目录")
@PostMapping("/{id}/export-workspace")
- public R> exportToWorkspace(@PathVariable Long id) {
+ @RequireWorkspaceRole("admin")
+ public R> exportToWorkspace(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
+ verifyResourceWorkspace(skill, workspaceId);
var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent());
if (path == null) {
return R.ok(Map.of("success", false, "message", "Failed to export workspace"));
@@ -677,8 +794,136 @@ public class SkillController {
@Operation(summary = "获取 skill 工作区信息")
@GetMapping("/{id}/workspace")
- public R> getWorkspaceInfo(@PathVariable Long id) {
+ @RequireWorkspaceRole("admin")
+ public R> getWorkspaceInfo(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
+ verifyResourceWorkspace(skill, workspaceId);
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
}
+
+ // ==================== Skill lifecycle & curator ====================
+
+ @Operation(summary = "钉住/取消钉住技能(钉住的技能不会被自动归档)")
+ @PostMapping("/{id}/pin")
+ @RequireWorkspaceRole("admin")
+ public R pin(@PathVariable Long id,
+ @RequestBody(required = false) PinRequest body,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ rejectVirtualSkillMutation(id);
+ verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
+ boolean pinned = body != null && Boolean.TRUE.equals(body.pinned());
+ return R.ok(skillLifecycleService.setPinned(id, pinned));
+ }
+
+ @Operation(summary = "手动归档技能")
+ @PostMapping("/{id}/archive")
+ @RequireWorkspaceRole("admin")
+ public R archive(@PathVariable Long id,
+ @RequestParam(defaultValue = "false") boolean force,
+ @RequestBody(required = false) ArchiveRequest body,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ rejectVirtualSkillMutation(id);
+ SkillEntity skill = skillService.getSkill(id);
+ verifyResourceWorkspace(skill, workspaceId);
+ if (Boolean.TRUE.equals(skill.getBuiltin())) {
+ throw new MateClawException("err.skill.builtin_not_archivable", 400,
+ "Cannot archive builtin skill: " + skill.getName());
+ }
+ String state = skill.getLifecycleState() == null ? "active" : skill.getLifecycleState();
+ if ("archived".equals(state)) {
+ throw new MateClawException("err.skill.already_archived", 409,
+ "Skill already archived: " + skill.getName());
+ }
+ // Bound skills are not silently archived: require an explicit
+ // second-pass confirmation (force=true) so the admin sees which
+ // agents lose the capability.
+ if (!force) {
+ List bound =
+ agentBindingService.enabledAgentsBoundToSkill(id);
+ if (!bound.isEmpty()) {
+ throw new ConfirmRequiredException("BOUND_SKILL_CONFIRM_REQUIRED",
+ "Skill is explicitly bound to " + bound.size()
+ + " agent(s); pass force=true to confirm", bound);
+ }
+ }
+ String reason = body != null && body.reason() != null ? body.reason() : "manual:admin";
+ skillLifecycleService.applyManual(skill, LifecycleTransition.TO_ARCHIVED,
+ LocalDateTime.now(), reason);
+ return R.ok(skillService.getSkill(id));
+ }
+
+ @Operation(summary = "恢复已归档的技能")
+ @PostMapping("/{id}/restore")
+ @RequireWorkspaceRole("admin")
+ public R restore(@PathVariable Long id,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ rejectVirtualSkillMutation(id);
+ verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
+ return R.ok(skillLifecycleService.restore(id));
+ }
+
+ @Operation(summary = "立即运行一次 curator 预览(dry-run)")
+ @PostMapping("/curator/dry-run")
+ @RequireWorkspaceRole("admin")
+ public R curatorDryRun() {
+ return R.ok(skillCuratorJob.dryRunNow());
+ }
+
+ @Operation(summary = "激活/取消激活 curator(真正归档 vs 仅预览)")
+ @PostMapping("/curator/activate")
+ @RequireWorkspaceRole("admin")
+ public R> curatorActivate(
+ @RequestParam(defaultValue = "true") boolean activate) {
+ skillCuratorJob.activate(activate);
+ return R.ok(skillCuratorJob.status());
+ }
+
+ @Operation(summary = "暂停 curator 定时扫描")
+ @PostMapping("/curator/pause")
+ @RequireWorkspaceRole("admin")
+ public R> curatorPause() {
+ skillCuratorJob.setPaused(true);
+ return R.ok(skillCuratorJob.status());
+ }
+
+ @Operation(summary = "恢复 curator 定时扫描")
+ @PostMapping("/curator/resume")
+ @RequireWorkspaceRole("admin")
+ public R> curatorResume() {
+ skillCuratorJob.setPaused(false);
+ return R.ok(skillCuratorJob.status());
+ }
+
+ @Operation(summary = "curator 控制面状态")
+ @GetMapping("/curator/status")
+ @RequireWorkspaceRole("member")
+ public R> curatorStatus() {
+ return R.ok(skillCuratorJob.status());
+ }
+
+ @Operation(summary = "列出最近的 curator 运行报告")
+ @GetMapping("/curator/reports")
+ @RequireWorkspaceRole("member")
+ public R> curatorReports() {
+ return R.ok(skillCuratorReportStore.listRunIds(20));
+ }
+
+ @Operation(summary = "读取某次 curator 运行报告")
+ @GetMapping("/curator/reports/{runId}")
+ @RequireWorkspaceRole("member")
+ public R curatorReport(@PathVariable String runId) {
+ Object report = skillCuratorReportStore.readRun(runId);
+ if (report == null) {
+ throw new MateClawException("err.skill.curator_report_not_found", 404,
+ "Curator report not found: " + runId);
+ }
+ return R.ok(report);
+ }
+
+ /** Body of {@code POST /skills/{id}/pin}. */
+ public record PinRequest(Boolean pinned) {}
+
+ /** Optional body of {@code POST /skills/{id}/archive}. */
+ public record ArchiveRequest(String reason) {}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java
index 91dea8a2..ac81767c 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import vip.mate.common.result.R;
import vip.mate.skill.installer.SkillInstaller;
+import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.skill.installer.ZipSkillFetcher;
import vip.mate.skill.installer.model.*;
import vip.mate.skill.runtime.SkillFrontmatterParser;
@@ -34,6 +35,7 @@ public class SkillInstallController {
@Operation(summary = "搜索 ClawHub 市场")
@GetMapping("/hub/search")
+ @RequireWorkspaceRole("admin")
public R> searchHub(
@RequestParam String q,
@RequestParam(defaultValue = "20") int limit) {
@@ -42,15 +44,21 @@ public class SkillInstallController {
@Operation(summary = "开始异步安装 skill")
@PostMapping("/start")
- public R startInstall(@RequestBody InstallRequest request) {
+ @RequireWorkspaceRole("admin")
+ public R startInstall(@RequestBody InstallRequest request,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
if (request.getBundleUrl() == null || request.getBundleUrl().isBlank()) {
return R.fail("bundleUrl is required");
}
+ // Stamp the owning workspace from the request context — never trust
+ // a workspaceId smuggled in the JSON body.
+ request.setWorkspaceId(workspaceId);
return R.ok(skillInstaller.startInstall(request));
}
@Operation(summary = "查询安装任务状态")
@GetMapping("/status/{taskId}")
+ @RequireWorkspaceRole("admin")
public R getStatus(@PathVariable String taskId) {
InstallTask task = skillInstaller.getTaskStatus(taskId);
if (task == null) {
@@ -61,6 +69,7 @@ public class SkillInstallController {
@Operation(summary = "取消安装任务")
@PostMapping("/cancel/{taskId}")
+ @RequireWorkspaceRole("admin")
public R cancel(@PathVariable String taskId) {
skillInstaller.cancelTask(taskId);
return R.ok();
@@ -68,11 +77,13 @@ public class SkillInstallController {
@Operation(summary = "上传 ZIP 安装 skill")
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @RequireWorkspaceRole("admin")
public R> uploadZip(
@RequestPart("file") MultipartFile zipFile,
@RequestParam(defaultValue = "true") Boolean enable,
@RequestParam(defaultValue = "false") Boolean overwrite,
- @RequestParam(required = false) String targetName) {
+ @RequestParam(required = false) String targetName,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// 校验文件类型
String filename = zipFile.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".zip")) {
@@ -80,7 +91,8 @@ public class SkillInstallController {
}
try {
SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser);
- Map result = skillInstaller.installFromBundle(bundle, enable, overwrite, targetName);
+ Map result = skillInstaller.installFromBundle(
+ bundle, enable, overwrite, targetName, workspaceId);
return R.ok(result);
} catch (IllegalArgumentException e) {
return R.fail(400, e.getMessage());
@@ -91,8 +103,10 @@ public class SkillInstallController {
@Operation(summary = "卸载 skill")
@DeleteMapping("/{skillName}")
- public R> uninstall(@PathVariable String skillName) {
- skillInstaller.uninstall(skillName);
+ @RequireWorkspaceRole("admin")
+ public R> uninstall(@PathVariable String skillName,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ skillInstaller.uninstall(skillName, workspaceId);
return R.ok(Map.of("message", "Skill '" + skillName + "' uninstalled"));
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/event/SkillRemovedEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/event/SkillRemovedEvent.java
new file mode 100644
index 00000000..70868637
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/event/SkillRemovedEvent.java
@@ -0,0 +1,17 @@
+package vip.mate.skill.event;
+
+/**
+ * Fires after a skill row has been removed from {@code mate_skill}, whether
+ * through the user-facing uninstall path or the admin hard-delete path.
+ *
+ * Downstream listeners use this to scrub records that reference the
+ * deleted skill — most importantly the agent-skill binding rows in
+ * {@code mate_agent_skill}, which would otherwise leave orphan bindings the
+ * UI can't unset (the binding count stays > 0 and the picker can no longer
+ * render the row to uncheck it).
+ *
+ * @param skillId DB id of the removed skill row
+ * @param skillName slug identifier the row carried, useful for log lines
+ */
+public record SkillRemovedEvent(Long skillId, String skillName) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java
index dd956087..dceb01b6 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java
@@ -103,7 +103,7 @@ public class BuiltinSkillSeedService implements ApplicationRunner {
// from the previous successful run AND the DB still holds the same
// number of builtin rows, nothing on disk changed since last seed
// and we can skip the parse / select / update loop entirely.
- // Hermes-style trick: stat-only check, no content read.
+ // The check is stat-only — no content read.
Map currentManifest = buildResourceManifest(resources);
SeedSnapshot snapshot = loadSnapshot();
if (snapshot != null
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java
index 8e0e4cf1..a3dee481 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java
@@ -81,7 +81,7 @@ public class SkillInstaller {
* admin-only physical removal, call
* {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}.
*/
- public void uninstall(String skillName) {
+ public void uninstall(String skillName, Long workspaceId) {
List skills = skillService.listSkills();
SkillEntity target = skills.stream()
.filter(s -> s.getName().equals(skillName))
@@ -89,6 +89,18 @@ public class SkillInstaller {
.orElse(null);
if (target != null) {
+ // A workspace may only uninstall the skills it owns. Builtin
+ // skills are global and rejected by uninstallSkill itself.
+ if (!Boolean.TRUE.equals(target.getBuiltin())) {
+ long requested = workspaceId != null
+ ? workspaceId : SkillService.DEFAULT_WORKSPACE_ID;
+ long owner = target.getWorkspaceId() != null
+ ? target.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID;
+ if (owner != requested) {
+ throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403,
+ "Skill '" + skillName + "' does not belong to the current workspace");
+ }
+ }
skillService.uninstallSkill(target.getId());
}
log.info("Uninstalled skill: {}", skillName);
@@ -159,7 +171,7 @@ public class SkillInstaller {
// 5. Register/update the skill row first so we have an id for the file rows.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists,
- Boolean.TRUE.equals(request.getEnable()));
+ Boolean.TRUE.equals(request.getEnable()), request.getWorkspaceId());
if (task.isCancelRequested()) {
task.markCancelled();
@@ -199,7 +211,8 @@ public class SkillInstaller {
*
* @return 安装结果 Map(skillId, name, version, filesCount)
*/
- public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) {
+ public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite,
+ String targetName, Long workspaceId) {
String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name();
if (skillName == null || skillName.isBlank()) {
throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle");
@@ -216,7 +229,7 @@ public class SkillInstaller {
workspaceManager.initWorkspace(skillName, bundle.content(), exists);
// Register/update skill row first so we have an id to anchor the file rows.
- SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable);
+ SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable, workspaceId);
// DB-canonical, FS-cache. Empty-bundle guard on both sides.
persistBundleFiles(skillEntity, bundle, false, "zip");
@@ -241,8 +254,13 @@ public class SkillInstaller {
/**
* Insert or update the {@code mate_skill} row from a bundle. Returns the
* persisted entity so callers have its id for downstream file writes.
+ *
+ * {@code workspaceId} is stamped only on the insert path — an
+ * existing skill keeps its current owning workspace so a re-install
+ * never silently migrates a skill between workspaces.
*/
- private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, boolean enable) {
+ private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists,
+ boolean enable, Long workspaceId) {
SkillEntity skillEntity;
if (exists) {
skillEntity = skillService.listSkills().stream()
@@ -267,6 +285,7 @@ public class SkillInstaller {
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(enable);
+ skillEntity.setWorkspaceId(workspaceId);
skillService.createSkill(skillEntity);
}
return skillEntity;
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java
index 0f4ac2ac..ef483df5 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java
@@ -5,8 +5,11 @@ import org.springframework.web.multipart.MultipartFile;
import vip.mate.skill.installer.model.SkillBundle;
import vip.mate.skill.runtime.SkillFrontmatterParser;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -135,12 +138,55 @@ public class ZipSkillFetcher {
* instead of being silently dropped.
*/
public static ExtractedSkill extract(InputStream zipStream) throws IOException {
+ return extract(zipStream.readAllBytes());
+ }
+
+ /** Fallback charset for archives authored on Chinese Windows (entry names / content in GBK). */
+ private static final Charset GBK = Charset.isSupported("GBK") ? Charset.forName("GBK") : null;
+
+ /**
+ * Decompress raw ZIP bytes, trying UTF-8 first and falling back to GBK when
+ * an entry name fails to decode as UTF-8 — the common failure mode for zips
+ * packaged on Chinese Windows, where filenames are GBK and UTF-8 decoding
+ * throws a {@link CharacterCodingException}. Buffering the bytes (rather than
+ * a one-shot stream) is what makes the retry possible.
+ */
+ public static ExtractedSkill extract(byte[] zipBytes) throws IOException {
+ try {
+ return extract(zipBytes, StandardCharsets.UTF_8);
+ } catch (IOException | RuntimeException e) {
+ if (GBK != null && isCharsetError(e)) {
+ log.warn("[ZipSkillFetcher] UTF-8 entry decode failed, retrying with GBK (Windows-authored archive?)");
+ return extract(zipBytes, GBK);
+ }
+ throw e;
+ }
+ }
+
+ /** True if {@code t} (or any cause) is a charset-decode failure, vs a genuine "no SKILL.md" error. */
+ private static boolean isCharsetError(Throwable t) {
+ for (Throwable c = t; c != null; c = c.getCause()) {
+ if (c instanceof CharacterCodingException) {
+ return true;
+ }
+ String m = c.getMessage();
+ if (m != null && m.toLowerCase().contains("malformed")) {
+ return true;
+ }
+ if (c.getCause() == c) {
+ break;
+ }
+ }
+ return false;
+ }
+
+ private static ExtractedSkill extract(byte[] zipBytes, Charset charset) throws IOException {
List raws = new ArrayList<>();
String skillMdContent = null;
String skillMdPrefix = "";
long totalSize = 0;
- try (ZipInputStream zis = new ZipInputStream(zipStream, StandardCharsets.UTF_8)) {
+ try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
@@ -150,6 +196,13 @@ public class ZipSkillFetcher {
String entryName = entry.getName();
+ // Skip macOS archive cruft so it doesn't surface as "ignored" noise.
+ if (entryName.startsWith("__MACOSX/") || entryName.equals(".DS_Store")
+ || entryName.endsWith("/.DS_Store")) {
+ zis.closeEntry();
+ continue;
+ }
+
// Zip Slip guard: normalize and reject absolute / traversal entries.
Path entryPath = Path.of(entryName).normalize();
if (entryPath.isAbsolute() || entryName.contains("..")) {
@@ -176,7 +229,7 @@ public class ZipSkillFetcher {
throw new IOException("Total extracted size exceeds 50MB limit");
}
- String content = new String(bytes, StandardCharsets.UTF_8);
+ String content = new String(bytes, charset);
String normalizedName = entryPath.toString().replace('\\', '/');
String fileName = entryPath.getFileName().toString();
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java
index 47143c17..f7ba22d6 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java
@@ -33,4 +33,11 @@ public class InstallRequest {
* an intentionally empty bundle.
*/
private Boolean forcePrune = false;
+
+ /**
+ * Owning workspace for the installed skill. Stamped by the controller
+ * from the {@code X-Workspace-Id} header — never trusted from a raw
+ * client body. {@code null} falls back to the default workspace.
+ */
+ private Long workspaceId;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java
new file mode 100644
index 00000000..5e2cbc3e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java
@@ -0,0 +1,17 @@
+package vip.mate.skill.lifecycle;
+
+import java.util.List;
+
+/**
+ * A skill that satisfies the curator's idle time window but is kept out of
+ * the candidate set because it is explicitly bound to one or more enabled
+ * agents. Surfaced in the run report so an admin can see what was held back.
+ *
+ * @author MateClaw Team
+ */
+public record BlockedByBindingRow(
+ Long skillId,
+ String name,
+ List agentIds,
+ long daysIdle) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java
new file mode 100644
index 00000000..e6dbc55a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java
@@ -0,0 +1,35 @@
+package vip.mate.skill.lifecycle;
+
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import java.util.List;
+
+/**
+ * Thrown by manual-archive when the requested action would impact resources
+ * the caller has not explicitly opted in to touching (a skill that is still
+ * explicitly bound to one or more enabled agents).
+ *
+ * The caller resolves the conflict by retrying with {@code force=true}.
+ * {@link ResponseStatus} maps this to HTTP 409 so clients can branch on the
+ * status code rather than parsing the body.
+ *
+ * @author MateClaw Team
+ */
+@Getter
+@ResponseStatus(HttpStatus.CONFLICT)
+public class ConfirmRequiredException extends RuntimeException {
+
+ private final String code;
+ private final List boundAgents;
+
+ public ConfirmRequiredException(String code, String message, List boundAgents) {
+ super(message);
+ this.code = code;
+ this.boundAgents = boundAgents == null ? List.of() : List.copyOf(boundAgents);
+ }
+
+ /** Minimal agent identity surfaced to the client so it can render a confirm dialog. */
+ public record AgentRow(Long id, String name) {}
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java
new file mode 100644
index 00000000..fff2e821
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java
@@ -0,0 +1,49 @@
+package vip.mate.skill.lifecycle;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+import vip.mate.audit.service.AuditEventService;
+
+import java.util.Map;
+
+/**
+ * Surfaces a completed lifecycle sweep through two decoupled channels: a
+ * durable {@code mate_audit_event} row, and a Spring application event that
+ * a notification subsystem may listen for. Neither channel couples the
+ * curator to any subsystem that may not be present.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CuratorRunNotifier {
+
+ private final AuditEventService auditEventService;
+ private final ApplicationEventPublisher eventPublisher;
+ private final ObjectMapper objectMapper;
+
+ public void onRunComplete(SkillCuratorReport report) {
+ // (1) Durable audit trail — always recorded.
+ try {
+ String detail = objectMapper.writeValueAsString(Map.of(
+ "marked_stale", report.markedStale(),
+ "archived", report.archived(),
+ "reactivated", report.reactivated(),
+ "dryRun", report.isDryRun(),
+ "reportPath", String.valueOf(report.getPath())));
+ auditEventService.record("CURATOR_RUN", "SKILL", report.getRunId(), null, detail);
+ } catch (Exception e) {
+ log.debug("Failed to record curator run audit event: {}", e.getMessage());
+ }
+
+ // (2) Application event — no listener is required; if none exists
+ // the event is simply discarded.
+ eventPublisher.publishEvent(new SkillCuratorRunCompletedEvent(
+ report.getRunId(), report.markedStale(), report.archived(),
+ report.reactivated(), report.isDryRun(), report.getPath(), report.getRunAt()));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java
new file mode 100644
index 00000000..2aec43ae
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java
@@ -0,0 +1,17 @@
+package vip.mate.skill.lifecycle;
+
+/**
+ * The transition a skill should undergo on a lifecycle sweep.
+ *
+ * @author MateClaw Team
+ */
+public enum LifecycleTransition {
+ /** No change needed. */
+ NONE,
+ /** active -> stale (idle past the stale threshold). */
+ TO_STALE,
+ /** stale -> archived (idle past the archive threshold). */
+ TO_ARCHIVED,
+ /** stale -> active (activity observed again). */
+ REACTIVATE
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java
new file mode 100644
index 00000000..28620ae0
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java
@@ -0,0 +1,289 @@
+package vip.mate.skill.lifecycle;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Component;
+import vip.mate.agent.binding.service.AgentBindingService;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.repository.SkillMapper;
+import vip.mate.skill.workspace.SkillWorkspaceManager;
+import vip.mate.system.service.SystemSettingService;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Daily sweep that ages idle, agent-created skills through the lifecycle
+ * state machine. Three gates guard the sweep: the config-level
+ * {@code enabled} switch, an operational {@code paused} kill switch, and a
+ * first-run throttle that keeps the pre-activation dry-run from flooding the
+ * report directory.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SkillCuratorJob {
+
+ /** Admin flipped the curator from preview-only to applying transitions. */
+ static final String FIRST_RUN_KEY = "skill.curator.firstRunCompleted";
+ /** Runtime kill switch — pauses the scheduled sweep without a redeploy. */
+ static final String PAUSED_KEY = "skill.curator.paused";
+ /** ISO-8601 timestamp of the last auto dry-run, for throttling. */
+ static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt";
+ /** ISO-8601 timestamp of the first sweep observation after install. */
+ static final String LAST_OBSERVED_KEY = "skill.curator.lastObservedAt";
+ /** ISO-8601 timestamp of the last sweep that produced a report. */
+ static final String LAST_RUN_KEY = "skill.curator.lastRunAt";
+
+ /** Minimum hours between auto dry-runs while the curator is not activated. */
+ private static final long DRY_RUN_THROTTLE_HOURS = 23;
+
+ private final SkillLifecycleService lifecycleService;
+ private final SkillMapper skillMapper;
+ private final SkillCuratorReportStore reportStore;
+ private final SkillLifecycleProperties properties;
+ private final SystemSettingService systemSettingService;
+ private final AgentBindingService agentBindingService;
+ private final SkillWorkspaceManager workspaceManager;
+ private final CuratorRunNotifier notifier;
+
+ @Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}")
+ @SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S")
+ public void run() {
+ // Gate 1: config-level enable.
+ if (!properties.isEnabled() || "OFF".equals(properties.getScope())) {
+ return;
+ }
+ // Gate 2: operational pause.
+ if (systemSettingService.getBool(PAUSED_KEY, false)) {
+ log.debug("Curator paused via {} — skipping this tick", PAUSED_KEY);
+ return;
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ boolean activated = systemSettingService.getBool(FIRST_RUN_KEY, false);
+
+ // Gate 3: first-run throttle. Before activation the sweep is
+ // informational; bound it to once per ~day so the report directory
+ // doesn't fill with identical previews.
+ if (!activated) {
+ LocalDateTime lastObserved = parseTs(systemSettingService.getString(LAST_OBSERVED_KEY, null));
+ LocalDateTime lastDry = parseTs(systemSettingService.getString(LAST_DRY_RUN_KEY, null));
+ if (lastObserved == null) {
+ systemSettingService.saveString(LAST_OBSERVED_KEY, now.toString(),
+ "Skill curator first observed timestamp");
+ log.info("Curator first observation — deferring; preview on demand via /curator/dry-run");
+ return;
+ }
+ Duration sinceLastDry = lastDry == null
+ ? Duration.between(lastObserved, now)
+ : Duration.between(lastDry, now);
+ if (sinceLastDry.toHours() < DRY_RUN_THROTTLE_HOURS) {
+ log.debug("Curator dry-run throttled ({}h since last)", sinceLastDry.toHours());
+ return;
+ }
+ }
+
+ boolean dryRun = !activated;
+ SkillCuratorReport report = sweep(now, dryRun);
+
+ if (dryRun) {
+ systemSettingService.saveString(LAST_DRY_RUN_KEY, now.toString(),
+ "Skill curator last dry-run timestamp");
+ }
+ systemSettingService.saveString(LAST_RUN_KEY, now.toString(),
+ "Skill curator last run timestamp");
+ notifier.onRunComplete(report);
+ }
+
+ /**
+ * Run a dry-run sweep immediately, bypassing the first-run throttle and
+ * the scheduler lock — for the admin "preview now" action.
+ */
+ public SkillCuratorReport dryRunNow() {
+ SkillCuratorReport report = sweep(LocalDateTime.now(), true);
+ notifier.onRunComplete(report);
+ return report;
+ }
+
+ /** Flip the activation flag (preview-only ⇄ applying). */
+ public void activate(boolean activate) {
+ systemSettingService.saveBool(FIRST_RUN_KEY, activate, "Skill curator activated");
+ }
+
+ /** Set the runtime pause flag. */
+ public void setPaused(boolean paused) {
+ systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused");
+ }
+
+ /** Aggregated control-panel state for the admin UI. */
+ public Map status() {
+ Map config = new LinkedHashMap<>();
+ config.put("enabled", properties.isEnabled());
+ config.put("scope", properties.getScope());
+ config.put("staleAfterDays", properties.getStaleAfterDays());
+ config.put("archiveAfterDays", properties.getArchiveAfterDays());
+ config.put("cron", properties.getCron());
+
+ Map control = new LinkedHashMap<>();
+ control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false));
+ control.put("paused", systemSettingService.getBool(PAUSED_KEY, false));
+ control.put("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null));
+ control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null));
+ control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null));
+ control.put("nextScheduledRun", nextScheduledRun());
+
+ Map counts = new LinkedHashMap<>();
+ counts.put("active", countState("active"));
+ counts.put("stale", countState("stale"));
+ counts.put("archived", countState("archived"));
+ counts.put("pinned", skillMapper.selectCount(
+ new LambdaQueryWrapper().eq(SkillEntity::getPinned, true)));
+ // Count only archival-relevant skills held back by a binding — same
+ // set the run report's blockedByBindings array shows, so the status
+ // count and the report stay consistent (builtin / mcp / acp / pinned
+ // skills are exempt regardless of bindings and are not counted here).
+ counts.put("blockedByBindings",
+ agentBindingService.blockedByBindingCandidates(LocalDateTime.now()).size());
+
+ Map out = new LinkedHashMap<>();
+ out.put("config", config);
+ out.put("control", control);
+ out.put("counts", counts);
+ String latest = reportStore.latestRunId();
+ out.put("lastReport", latest == null ? null : Map.of(
+ "id", latest,
+ "url", "/api/v1/skills/curator/reports/" + latest));
+ return out;
+ }
+
+ // ==================== Internals ====================
+
+ private SkillCuratorReport sweep(LocalDateTime now, boolean dryRun) {
+ SkillCuratorReport.Builder report = SkillCuratorReport.builder()
+ .runAt(now)
+ .dryRun(dryRun)
+ .config(properties.getStaleAfterDays(), properties.getArchiveAfterDays(),
+ properties.getScope());
+
+ reconcileOrphans(now, report, dryRun);
+
+ List candidates = loadCandidates();
+ int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0;
+ int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0;
+ for (SkillEntity skill : candidates) {
+ LifecycleTransition t = lifecycleService.planTransition(skill, now);
+ report.add(skill, t);
+ if (t == LifecycleTransition.TO_STALE) {
+ plannedStale++;
+ } else if (t == LifecycleTransition.TO_ARCHIVED) {
+ plannedArchived++;
+ } else if (t == LifecycleTransition.REACTIVATE) {
+ plannedReactivate++;
+ }
+ if (dryRun) {
+ continue;
+ }
+ boolean applied = lifecycleService.apply(skill, t, now);
+ if (applied) {
+ if (t == LifecycleTransition.TO_STALE) {
+ appliedStale++;
+ } else if (t == LifecycleTransition.TO_ARCHIVED) {
+ appliedArchived++;
+ } else if (t == LifecycleTransition.REACTIVATE) {
+ appliedReactivate++;
+ }
+ }
+ }
+
+ report.scanned(candidates.size())
+ .plannedCounts(plannedStale, plannedArchived, plannedReactivate)
+ .appliedCounts(appliedStale, appliedArchived, appliedReactivate)
+ .blockedByBindings(agentBindingService.blockedByBindingCandidates(now));
+
+ return reportStore.write(report.build());
+ }
+
+ /**
+ * Candidate skills for the state machine: not builtin, not pinned, not a
+ * builtin/mcp/acp type, not bound to any enabled agent, and — under the
+ * default {@code AGENT_CREATED} scope — created by an agent.
+ */
+ private List loadCandidates() {
+ Set bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents();
+
+ LambdaQueryWrapper w = new LambdaQueryWrapper()
+ .eq(SkillEntity::getBuiltin, false)
+ .eq(SkillEntity::getPinned, false)
+ .notIn(SkillEntity::getSkillType, List.of("builtin", "mcp", "acp"));
+ if (!bindingProtected.isEmpty()) {
+ w.notIn(SkillEntity::getId, bindingProtected);
+ }
+ if ("AGENT_CREATED".equals(properties.getScope())) {
+ w.isNotNull(SkillEntity::getSourceConversationId);
+ }
+ return skillMapper.selectList(w);
+ }
+
+ /**
+ * Heal the unambiguous divergence class: a row marked {@code archived}
+ * whose convention workspace is back in place (an admin moved a directory
+ * or a re-install ran). The reverse class — workspace moved but the DB
+ * write failed — is handled inline by the archive compensation path.
+ */
+ private void reconcileOrphans(LocalDateTime now, SkillCuratorReport.Builder report, boolean dryRun) {
+ List archived = skillMapper.selectList(new LambdaQueryWrapper()
+ .eq(SkillEntity::getLifecycleState, "archived"));
+ for (SkillEntity skill : archived) {
+ if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName())) {
+ continue;
+ }
+ report.reconciliation("skill '" + skill.getName() + "' (id=" + skill.getId()
+ + ") archived in DB but workspace present — reactivating");
+ if (!dryRun) {
+ skillMapper.update(null, new LambdaUpdateWrapper()
+ .eq(SkillEntity::getId, skill.getId())
+ .set(SkillEntity::getLifecycleState, "active")
+ .set(SkillEntity::getEnabled, true)
+ .set(SkillEntity::getArchivedAt, null)
+ .set(SkillEntity::getLastActivityAt, now));
+ }
+ }
+ }
+
+ private long countState(String state) {
+ return skillMapper.selectCount(new LambdaQueryWrapper()
+ .eq(SkillEntity::getLifecycleState, state));
+ }
+
+ private String nextScheduledRun() {
+ try {
+ LocalDateTime next = CronExpression.parse(properties.getCron()).next(LocalDateTime.now());
+ return next != null ? next.toString() : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static LocalDateTime parseTs(String s) {
+ if (s == null || s.isBlank()) {
+ return null;
+ }
+ try {
+ return LocalDateTime.parse(s);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java
new file mode 100644
index 00000000..a2cb8b79
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java
@@ -0,0 +1,173 @@
+package vip.mate.skill.lifecycle;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Getter;
+import vip.mate.skill.model.SkillEntity;
+
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Structured result of one lifecycle sweep — serialized to {@code run.json}
+ * and rendered to {@code REPORT.md}. Built incrementally during the sweep
+ * via {@link Builder}.
+ *
+ * {@code planned} counts reflect what {@code planTransition} decided and
+ * are populated in both dry-run and applied modes. {@code applied} counts
+ * reflect what actually committed and stay zero for a dry-run.
+ *
+ * @author MateClaw Team
+ */
+@Getter
+public class SkillCuratorReport {
+
+ private static final DateTimeFormatter RUN_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
+
+ private final String runId;
+ private final LocalDateTime runAt;
+ private final boolean dryRun;
+ private final Config config;
+ private final int scanned;
+ private final Counts planned;
+ private final Counts applied;
+ private final List transitions;
+ private final List blockedByBindings;
+ private final List reconciliations;
+
+ /** Set by the report store after the run directory is written. */
+ @JsonIgnore
+ private Path path;
+
+ private SkillCuratorReport(Builder b) {
+ this.runAt = b.runAt != null ? b.runAt : LocalDateTime.now();
+ this.runId = this.runAt.format(RUN_ID);
+ this.dryRun = b.dryRun;
+ this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope);
+ this.scanned = b.scanned;
+ this.planned = new Counts(b.plannedStale, b.plannedArchived, b.plannedReactivated);
+ this.applied = new Counts(b.appliedStale, b.appliedArchived, b.appliedReactivated);
+ this.transitions = List.copyOf(b.transitions);
+ this.blockedByBindings = List.copyOf(b.blockedByBindings);
+ this.reconciliations = List.copyOf(b.reconciliations);
+ }
+
+ public void setPath(Path path) {
+ this.path = path;
+ }
+
+ /** Applied count of skills marked stale (0 for a dry-run). */
+ public int markedStale() {
+ return applied.stale();
+ }
+
+ /** Applied count of skills archived (0 for a dry-run). */
+ public int archived() {
+ return applied.archived();
+ }
+
+ /** Applied count of skills reactivated (0 for a dry-run). */
+ public int reactivated() {
+ return applied.reactivated();
+ }
+
+ public record Config(int staleAfterDays, int archiveAfterDays, String scope) {}
+
+ public record Counts(int stale, int archived, int reactivated) {}
+
+ public record TransitionRow(Long skillId, String name, String from, String to, long daysIdle) {}
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** Incremental builder used by the sweep. */
+ public static final class Builder {
+ private LocalDateTime runAt;
+ private boolean dryRun;
+ private int staleAfterDays;
+ private int archiveAfterDays;
+ private String scope;
+ private int scanned;
+ private int plannedStale, plannedArchived, plannedReactivated;
+ private int appliedStale, appliedArchived, appliedReactivated;
+ private final List transitions = new ArrayList<>();
+ private List blockedByBindings = new ArrayList<>();
+ private final List reconciliations = new ArrayList<>();
+
+ public Builder runAt(LocalDateTime runAt) {
+ this.runAt = runAt;
+ return this;
+ }
+
+ public Builder dryRun(boolean dryRun) {
+ this.dryRun = dryRun;
+ return this;
+ }
+
+ public Builder config(int staleAfterDays, int archiveAfterDays, String scope) {
+ this.staleAfterDays = staleAfterDays;
+ this.archiveAfterDays = archiveAfterDays;
+ this.scope = scope;
+ return this;
+ }
+
+ public Builder scanned(int scanned) {
+ this.scanned = scanned;
+ return this;
+ }
+
+ /** Record a non-NONE transition for a skill in the {@code transitions} list. */
+ public Builder add(SkillEntity skill, LifecycleTransition t) {
+ if (t == null || t == LifecycleTransition.NONE) {
+ return this;
+ }
+ LocalDateTime anchor = skill.getLastActivityAt() != null
+ ? skill.getLastActivityAt() : skill.getCreateTime();
+ long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays();
+ String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
+ String to = switch (t) {
+ case TO_STALE -> "stale";
+ case TO_ARCHIVED -> "archived";
+ case REACTIVATE -> "active";
+ case NONE -> from;
+ };
+ transitions.add(new TransitionRow(skill.getId(), skill.getName(), from, to, days));
+ return this;
+ }
+
+ public Builder plannedCounts(int stale, int archived, int reactivated) {
+ this.plannedStale = stale;
+ this.plannedArchived = archived;
+ this.plannedReactivated = reactivated;
+ return this;
+ }
+
+ public Builder appliedCounts(int stale, int archived, int reactivated) {
+ this.appliedStale = stale;
+ this.appliedArchived = archived;
+ this.appliedReactivated = reactivated;
+ return this;
+ }
+
+ public Builder blockedByBindings(List rows) {
+ this.blockedByBindings = rows != null ? rows : new ArrayList<>();
+ return this;
+ }
+
+ public Builder reconciliation(String message) {
+ if (message != null && !message.isBlank()) {
+ this.reconciliations.add(message);
+ }
+ return this;
+ }
+
+ public SkillCuratorReport build() {
+ return new SkillCuratorReport(this);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java
new file mode 100644
index 00000000..1843d2c3
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java
@@ -0,0 +1,204 @@
+package vip.mate.skill.lifecycle;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import vip.mate.skill.workspace.SkillWorkspaceManager;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Persists lifecycle sweep reports to {@code {workspace-root}/.curator/}.
+ * Each run gets a {@code {runId}/} directory holding {@code run.json} (the
+ * structured record) and {@code REPORT.md} (a human-readable render); a
+ * {@code latest} symlink points at the newest run.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SkillCuratorReportStore {
+
+ /** Number of run directories kept on disk; older ones are pruned. */
+ private static final int KEEP_RUNS = 50;
+
+ /** Run ids are {@code yyyyMMdd-HHmmss} — validated before any path resolve. */
+ private static final Pattern RUN_ID = Pattern.compile("\\d{8}-\\d{6}");
+
+ private final SkillWorkspaceManager workspaceManager;
+ private final ObjectMapper objectMapper;
+
+ private Path curatorRoot() {
+ return workspaceManager.getWorkspaceRoot().resolve(".curator");
+ }
+
+ /**
+ * Write the report's run directory and update the {@code latest}
+ * symlink. The report's {@code path} is populated on success.
+ */
+ public SkillCuratorReport write(SkillCuratorReport report) {
+ Path runDir = curatorRoot().resolve(report.getRunId());
+ try {
+ Files.createDirectories(runDir);
+ objectMapper.writerWithDefaultPrettyPrinter()
+ .writeValue(runDir.resolve("run.json").toFile(), report);
+ Files.writeString(runDir.resolve("REPORT.md"), renderMarkdown(report));
+ report.setPath(runDir);
+ updateLatest(runDir);
+ pruneOld();
+ } catch (IOException e) {
+ log.warn("Failed to write curator report {}: {}", report.getRunId(), e.getMessage());
+ }
+ return report;
+ }
+
+ /** Most recent run ids, newest first, capped at {@code limit}. */
+ public List listRunIds(int limit) {
+ Path root = curatorRoot();
+ if (!Files.isDirectory(root)) {
+ return List.of();
+ }
+ try (var stream = Files.list(root)) {
+ return stream
+ .filter(Files::isDirectory)
+ .map(p -> p.getFileName().toString())
+ .filter(n -> RUN_ID.matcher(n).matches())
+ .sorted(Comparator.reverseOrder())
+ .limit(limit > 0 ? limit : 20)
+ .toList();
+ } catch (IOException e) {
+ log.warn("Failed to list curator reports: {}", e.getMessage());
+ return List.of();
+ }
+ }
+
+ /** Newest run id, or {@code null} when no run has been recorded yet. */
+ public String latestRunId() {
+ List ids = listRunIds(1);
+ return ids.isEmpty() ? null : ids.get(0);
+ }
+
+ /**
+ * Parsed {@code run.json} for a run, or {@code null} when the run is
+ * unknown. The {@code runId} is validated against the timestamp pattern
+ * before being resolved as a path component.
+ */
+ public Object readRun(String runId) {
+ if (runId == null || !RUN_ID.matcher(runId).matches()) {
+ return null;
+ }
+ Path runJson = curatorRoot().resolve(runId).resolve("run.json");
+ if (!Files.isRegularFile(runJson)) {
+ return null;
+ }
+ try {
+ return objectMapper.readValue(runJson.toFile(), Object.class);
+ } catch (IOException e) {
+ log.warn("Failed to read curator report {}: {}", runId, e.getMessage());
+ return null;
+ }
+ }
+
+ private void updateLatest(Path runDir) {
+ Path latest = curatorRoot().resolve("latest");
+ try {
+ Files.deleteIfExists(latest);
+ Files.createSymbolicLink(latest, runDir.getFileName());
+ } catch (IOException | UnsupportedOperationException e) {
+ // Symlinks may be unsupported (Windows without privilege) — the
+ // latest run is still discoverable via listRunIds().
+ log.debug("Curator 'latest' symlink not updated: {}", e.getMessage());
+ }
+ }
+
+ private void pruneOld() {
+ List ids = listRunIds(Integer.MAX_VALUE);
+ if (ids.size() <= KEEP_RUNS) {
+ return;
+ }
+ for (String old : ids.subList(KEEP_RUNS, ids.size())) {
+ deleteRecursively(curatorRoot().resolve(old));
+ }
+ }
+
+ private void deleteRecursively(Path dir) {
+ if (!Files.exists(dir)) {
+ return;
+ }
+ try (var stream = Files.walk(dir)) {
+ stream.sorted(Comparator.reverseOrder()).forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ignored) {
+ /* best-effort prune */
+ }
+ });
+ } catch (IOException e) {
+ log.debug("Failed to prune curator report {}: {}", dir, e.getMessage());
+ }
+ }
+
+ private String renderMarkdown(SkillCuratorReport r) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("# Skill Curator Run ").append(r.getRunId()).append("\n\n");
+ sb.append("- Run at: ").append(r.getRunAt()).append('\n');
+ sb.append("- Mode: ").append(r.isDryRun() ? "dry-run (preview)" : "applied").append('\n');
+ sb.append("- Scope: ").append(r.getConfig().scope())
+ .append(" (stale ≥ ").append(r.getConfig().staleAfterDays())
+ .append("d, archive ≥ ").append(r.getConfig().archiveAfterDays()).append("d)\n");
+ sb.append("- Scanned: ").append(r.getScanned()).append(" candidate(s)\n\n");
+
+ sb.append("## Planned\n\n");
+ sb.append("| stale | archived | reactivated |\n|---|---|---|\n");
+ sb.append("| ").append(r.getPlanned().stale())
+ .append(" | ").append(r.getPlanned().archived())
+ .append(" | ").append(r.getPlanned().reactivated()).append(" |\n\n");
+
+ sb.append("## Applied\n\n");
+ sb.append("| stale | archived | reactivated |\n|---|---|---|\n");
+ sb.append("| ").append(r.getApplied().stale())
+ .append(" | ").append(r.getApplied().archived())
+ .append(" | ").append(r.getApplied().reactivated()).append(" |\n\n");
+
+ if (!r.getTransitions().isEmpty()) {
+ sb.append("## Transitions\n\n");
+ sb.append("| skill | from | to | days idle |\n|---|---|---|---|\n");
+ for (SkillCuratorReport.TransitionRow t : r.getTransitions()) {
+ sb.append("| ").append(t.name()).append(" (").append(t.skillId()).append(')')
+ .append(" | ").append(t.from())
+ .append(" | ").append(t.to())
+ .append(" | ").append(t.daysIdle()).append(" |\n");
+ }
+ sb.append('\n');
+ }
+
+ if (!r.getBlockedByBindings().isEmpty()) {
+ sb.append("## Blocked by agent bindings\n\n");
+ sb.append("These skills satisfy the idle window but are kept because an "
+ + "enabled agent explicitly binds them.\n\n");
+ sb.append("| skill | bound agents | days idle |\n|---|---|---|\n");
+ for (BlockedByBindingRow b : r.getBlockedByBindings()) {
+ sb.append("| ").append(b.name()).append(" (").append(b.skillId()).append(')')
+ .append(" | ").append(b.agentIds())
+ .append(" | ").append(b.daysIdle()).append(" |\n");
+ }
+ sb.append('\n');
+ }
+
+ if (!r.getReconciliations().isEmpty()) {
+ sb.append("## Reconciliations\n\n");
+ for (String line : r.getReconciliations()) {
+ sb.append("- ").append(line).append('\n');
+ }
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java
new file mode 100644
index 00000000..1c593403
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java
@@ -0,0 +1,24 @@
+package vip.mate.skill.lifecycle;
+
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+
+/**
+ * Published after every lifecycle sweep completes. Carries the applied
+ * counts (zero for a dry-run) so a downstream notification listener can
+ * surface the run without re-reading the report file.
+ *
+ * This event has no compile-time dependency on any notification
+ * subsystem: if nothing listens, it is simply a no-op.
+ *
+ * @author MateClaw Team
+ */
+public record SkillCuratorRunCompletedEvent(
+ String runId,
+ int markedStale,
+ int archived,
+ int reactivated,
+ boolean dryRun,
+ Path reportPath,
+ LocalDateTime runAt) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java
new file mode 100644
index 00000000..4c386593
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java
@@ -0,0 +1,14 @@
+package vip.mate.skill.lifecycle;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Auto-configuration for the skill lifecycle curator.
+ *
+ * @author MateClaw Team
+ */
+@Configuration
+@EnableConfigurationProperties(SkillLifecycleProperties.class)
+public class SkillLifecycleAutoConfiguration {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java
new file mode 100644
index 00000000..9ed64593
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java
@@ -0,0 +1,45 @@
+package vip.mate.skill.lifecycle;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Configuration for the skill lifecycle curator — the daily job that moves
+ * idle, agent-created skills through {@code active -> stale -> archived}.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@ConfigurationProperties(prefix = "mateclaw.skill.curator")
+public class SkillLifecycleProperties {
+
+ /** Master switch. When {@code false} the daily sweep never runs. */
+ private boolean enabled = true;
+
+ /** Cron expression for the daily sweep. Defaults to 02:00 every day. */
+ private String cron = "0 0 2 * * *";
+
+ /** Days of inactivity after which an active skill becomes {@code stale}. */
+ private int staleAfterDays = 30;
+
+ /** Days of inactivity after which a stale skill becomes {@code archived}. */
+ private int archiveAfterDays = 90;
+
+ /**
+ * Which skills the curator considers:
+ *
+ * {@code AGENT_CREATED} — only skills with a source conversation
+ * (created by an agent); the most conservative default.
+ * {@code ALL_DYNAMIC} — also includes manually-created dynamic
+ * skills.
+ * {@code OFF} — disables the sweep regardless of {@link #enabled}.
+ *
+ */
+ private String scope = "AGENT_CREATED";
+
+ /** Skills whose name starts with any of these prefixes are never touched. */
+ private List protectPrefixes = new ArrayList<>(List.of("sys-", "ops-"));
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java
new file mode 100644
index 00000000..be149025
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java
@@ -0,0 +1,329 @@
+package vip.mate.skill.lifecycle;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import vip.mate.audit.service.AuditEventService;
+import vip.mate.exception.MateClawException;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.repository.SkillMapper;
+import vip.mate.skill.runtime.SkillRuntimeService;
+import vip.mate.skill.workspace.SkillWorkspaceManager;
+import vip.mate.skill.workspace.SkillWorkspaceProperties;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * State machine primitives for the skill lifecycle curator. Holds every
+ * mutation a skill can undergo as it ages out: {@code active -> stale ->
+ * archived}, plus the reverse {@code restore} and the activity bump that
+ * keeps an in-use skill anchored to the present.
+ *
+ * All writes use {@link LambdaUpdateWrapper} whitelists rather than
+ * {@code updateById(entity)} so {@code FieldStrategy.ALWAYS} columns are
+ * never wiped by a partially-populated entity.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+public class SkillLifecycleService {
+
+ private final SkillMapper skillMapper;
+ private final SkillWorkspaceManager workspaceManager;
+ private final SkillWorkspaceProperties workspaceProperties;
+ private final SkillRuntimeService runtimeService;
+ private final AuditEventService auditEventService;
+ private final ObjectMapper objectMapper;
+ private final SkillLifecycleProperties properties;
+
+ /**
+ * {@code @Lazy} on {@code runtimeService} breaks the construction cycle
+ * {@code SkillService -> SkillLifecycleService -> SkillRuntimeService ->
+ * SkillService}.
+ */
+ @Autowired
+ public SkillLifecycleService(SkillMapper skillMapper,
+ SkillWorkspaceManager workspaceManager,
+ SkillWorkspaceProperties workspaceProperties,
+ @Lazy SkillRuntimeService runtimeService,
+ AuditEventService auditEventService,
+ ObjectMapper objectMapper,
+ SkillLifecycleProperties properties) {
+ this.skillMapper = skillMapper;
+ this.workspaceManager = workspaceManager;
+ this.workspaceProperties = workspaceProperties;
+ this.runtimeService = runtimeService;
+ this.auditEventService = auditEventService;
+ this.objectMapper = objectMapper;
+ this.properties = properties;
+ }
+
+ // ==================== Pure decision functions ====================
+
+ /** Activity anchor: last recorded activity, falling back to creation time. */
+ public LocalDateTime anchor(SkillEntity skill) {
+ if (skill.getLastActivityAt() != null) {
+ return skill.getLastActivityAt();
+ }
+ return skill.getCreateTime();
+ }
+
+ /** Skills the curator must never touch (filtered out before the state machine). */
+ public boolean isExempt(SkillEntity skill) {
+ if (Boolean.TRUE.equals(skill.getBuiltin())) {
+ return true;
+ }
+ if (Boolean.TRUE.equals(skill.getPinned())) {
+ return true;
+ }
+ String type = skill.getSkillType();
+ if (type == null || List.of("builtin", "mcp", "acp").contains(type)) {
+ return true;
+ }
+ String name = skill.getName();
+ if (name != null) {
+ for (String prefix : properties.getProtectPrefixes()) {
+ if (prefix != null && !prefix.isBlank() && name.startsWith(prefix)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Decide the transition for a skill at time {@code now}. Pure function:
+ * no side effects, no I/O — driven entirely by the entity's anchor and
+ * lifecycle state against the configured day thresholds.
+ */
+ public LifecycleTransition planTransition(SkillEntity skill, LocalDateTime now) {
+ if (isExempt(skill)) {
+ return LifecycleTransition.NONE;
+ }
+ LocalDateTime anchor = anchor(skill);
+ if (anchor == null) {
+ return LifecycleTransition.NONE;
+ }
+ long days = Duration.between(anchor, now).toDays();
+ String state = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
+ if (days >= properties.getArchiveAfterDays()) {
+ return "archived".equals(state) ? LifecycleTransition.NONE : LifecycleTransition.TO_ARCHIVED;
+ }
+ if (days >= properties.getStaleAfterDays()) {
+ return ("stale".equals(state) || "archived".equals(state))
+ ? LifecycleTransition.NONE : LifecycleTransition.TO_STALE;
+ }
+ return "stale".equals(state) ? LifecycleTransition.REACTIVATE : LifecycleTransition.NONE;
+ }
+
+ // ==================== Mutations ====================
+
+ /**
+ * Apply a planned transition. Returns {@code true} when the transition
+ * actually committed — an archive that fails at the workspace move or
+ * the DB write returns {@code false} so the caller can report
+ * {@code applied < planned}.
+ */
+ public boolean apply(SkillEntity skill, LifecycleTransition t, LocalDateTime now) {
+ return applyManual(skill, t, now, defaultReason(t));
+ }
+
+ /**
+ * Same as {@link #apply} but with an explicit audit reason — used by the
+ * admin-triggered manual archive so the audit trail records intent.
+ */
+ public boolean applyManual(SkillEntity skill, LifecycleTransition t, LocalDateTime now, String reason) {
+ return switch (t) {
+ case TO_STALE -> mark(skill, "stale");
+ case TO_ARCHIVED -> archive(skill, now, reason);
+ case REACTIVATE -> mark(skill, "active");
+ case NONE -> false;
+ };
+ }
+
+ /**
+ * Restore an archived skill: move its workspace back (when one was
+ * archived), flip the row to {@code active}, and refresh the runtime
+ * cache. DB-only skills with no archived workspace are a legitimate path
+ * — they restore on the DB write alone as long as {@code skill_content}
+ * still holds the body.
+ */
+ public SkillEntity restore(Long id) {
+ SkillEntity skill = skillMapper.selectById(id);
+ if (skill == null) {
+ throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id);
+ }
+ if (!"archived".equals(skill.getLifecycleState())) {
+ throw new MateClawException("err.skill.not_archived", 409,
+ "Skill is not archived: " + skill.getName());
+ }
+
+ SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName());
+ switch (fs) {
+ case MOVED -> { /* normal path */ }
+ case MISSING -> {
+ if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) {
+ throw new MateClawException("err.skill.unrecoverable", 409,
+ "Skill has no workspace archive and no skill content — cannot restore");
+ }
+ log.warn("Restoring DB-only skill '{}' (no workspace archive)", skill.getName());
+ }
+ case FAILED -> throw new MateClawException("err.skill.restore_failed", 500,
+ "Workspace archive exists but move-back failed; check disk / permissions");
+ }
+
+ skillMapper.update(null, new LambdaUpdateWrapper()
+ .eq(SkillEntity::getId, id)
+ .set(SkillEntity::getEnabled, true)
+ .set(SkillEntity::getLifecycleState, "active")
+ .set(SkillEntity::getArchivedAt, null)
+ .set(SkillEntity::getLastActivityAt, LocalDateTime.now()));
+
+ runtimeService.refreshActiveSkills();
+ recordAudit("RESTORE", skill, Map.of("fs", fs.name(), "to", "active"));
+ return skillMapper.selectById(id);
+ }
+
+ /**
+ * Pin or unpin a skill. A pinned skill is permanently exempt from the
+ * automatic state machine until unpinned.
+ */
+ public SkillEntity setPinned(Long id, boolean pinned) {
+ SkillEntity skill = skillMapper.selectById(id);
+ if (skill == null) {
+ throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id);
+ }
+ skillMapper.update(null, new LambdaUpdateWrapper()
+ .eq(SkillEntity::getId, id)
+ .set(SkillEntity::getPinned, pinned));
+ recordAudit(pinned ? "PIN" : "UNPIN", skill, Map.of("pinned", pinned));
+ return skillMapper.selectById(id);
+ }
+
+ /**
+ * Push the activity anchor of a skill to now and pull it back to
+ * {@code active} if it had drifted to {@code stale}. Best-effort: a
+ * write failure is logged, never thrown — losing one bump only delays
+ * the curator by a day. Archived skills are left untouched (recovering
+ * an archived skill must go through {@link #restore}).
+ */
+ public void bumpActivity(Long skillId) {
+ if (skillId == null) {
+ return;
+ }
+ try {
+ skillMapper.update(null, new LambdaUpdateWrapper()
+ .eq(SkillEntity::getId, skillId)
+ .and(w -> w.isNull(SkillEntity::getLifecycleState)
+ .or().ne(SkillEntity::getLifecycleState, "archived"))
+ .set(SkillEntity::getLastActivityAt, LocalDateTime.now())
+ .set(SkillEntity::getLifecycleState, "active"));
+ } catch (Exception e) {
+ log.debug("Failed to bump activity for skill {}: {}", skillId, e.getMessage());
+ }
+ }
+
+ // ==================== Internals ====================
+
+ private boolean mark(SkillEntity skill, String toState) {
+ String prevState = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
+ if (prevState.equals(toState)) {
+ return false;
+ }
+ int rows;
+ try {
+ rows = skillMapper.update(null, new LambdaUpdateWrapper()
+ .eq(SkillEntity::getId, skill.getId())
+ .set(SkillEntity::getLifecycleState, toState));
+ } catch (Exception e) {
+ log.warn("Skill '{}' lifecycle mark to {} failed: {}", skill.getName(), toState, e.getMessage());
+ return false;
+ }
+ if (rows == 0) {
+ return false;
+ }
+ recordAudit("LIFECYCLE", skill, Map.of("from", prevState, "to", toState));
+ return true;
+ }
+
+ /**
+ * Archive a skill: move its workspace to {@code .archived/}, then flip
+ * the row. The filesystem move runs before the DB write so a DB failure
+ * can be compensated by moving the workspace back. Returns {@code false}
+ * (no commit) when the workspace move fails or the DB write touches no
+ * rows — the next sweep retries.
+ */
+ private boolean archive(SkillEntity skill, LocalDateTime now, String reason) {
+ // Step 1: workspace move. MISSING is commit-safe (DB-only skill);
+ // FAILED defers the whole transition.
+ SkillWorkspaceManager.ArchiveResult fsResult = SkillWorkspaceManager.ArchiveResult.MISSING;
+ if ("archive".equals(workspaceProperties.getDeletePolicy())) {
+ fsResult = workspaceManager.archiveWorkspace(skill.getName());
+ }
+ if (fsResult == SkillWorkspaceManager.ArchiveResult.FAILED) {
+ log.warn("Skill '{}' workspace archive failed; deferring DB transition", skill.getName());
+ return false;
+ }
+
+ // Step 2: DB flip — guarded by affected-row count + compensation.
+ String prevState = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
+ int rows = 0;
+ try {
+ rows = skillMapper.update(null, new LambdaUpdateWrapper()
+ .eq(SkillEntity::getId, skill.getId())
+ .set(SkillEntity::getEnabled, false)
+ .set(SkillEntity::getLifecycleState, "archived")
+ .set(SkillEntity::getArchivedAt, now));
+ } catch (Exception e) {
+ log.error("Skill '{}' DB archive write failed; attempting compensation", skill.getName(), e);
+ }
+ if (rows == 0) {
+ log.warn("Skill '{}' DB archive update touched 0 rows; compensating workspace", skill.getName());
+ if (fsResult == SkillWorkspaceManager.ArchiveResult.MOVED) {
+ workspaceManager.restoreWorkspace(skill.getName());
+ }
+ return false;
+ }
+
+ // Mirror the uninstall path: deregister wrapper tools AND refresh the
+ // active-skill cache so an in-flight prompt build stops seeing the row.
+ runtimeService.deregisterSkillWrappers(skill.getId());
+ runtimeService.refreshActiveSkills();
+
+ recordAudit("ARCHIVE", skill, Map.of(
+ "reason", reason,
+ "anchor", String.valueOf(anchor(skill)),
+ "from", prevState,
+ "to", "archived",
+ "fs", fsResult.name()));
+ return true;
+ }
+
+ private String defaultReason(LifecycleTransition t) {
+ return switch (t) {
+ case TO_STALE -> "idle>=" + properties.getStaleAfterDays() + "d";
+ case TO_ARCHIVED -> "idle>=" + properties.getArchiveAfterDays() + "d";
+ case REACTIVATE -> "activity-observed";
+ case NONE -> "";
+ };
+ }
+
+ private void recordAudit(String action, SkillEntity skill, Map detail) {
+ String json;
+ try {
+ json = objectMapper.writeValueAsString(detail);
+ } catch (Exception e) {
+ json = String.valueOf(detail);
+ }
+ auditEventService.record(action, "SKILL",
+ String.valueOf(skill.getId()), skill.getName(), json);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java
index ece5db00..eb79ea27 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java
@@ -91,6 +91,18 @@ public class SkillManifest {
/** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */
private AcpBinding acp;
+ // ==================== type=code script entrypoints ====================
+
+ /**
+ * Declared script entrypoints from the {@code scripts} frontmatter
+ * block. Each entry is exposed to the model as a typed wrapper tool —
+ * the model fills schema-described fields and the runtime serializes
+ * them into process arguments, so a script consuming a JSON payload
+ * never depends on the model hand-crafting a JSON string.
+ */
+ @Builder.Default
+ private List scripts = List.of();
+
// ==================== Forward-compat catch-all ====================
/** Unknown frontmatter keys are stashed here so a future field
@@ -198,6 +210,45 @@ public class SkillManifest {
private Long resolvedEndpointId;
}
+ /**
+ * One script entrypoint declared under the {@code scripts} block. The
+ * resolver turns each into a typed wrapper tool named
+ * {@code skill__}.
+ */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public static class ScriptDef {
+ /** Stable id; forms the suffix of the generated wrapper tool name. */
+ private String id;
+ /** Human-readable label for the entrypoint. */
+ private String label;
+ /** Script path relative to the skill directory (e.g. {@code scripts/run.py}). */
+ private String path;
+ /** What the entrypoint does — surfaced as the wrapper tool description. */
+ private String description;
+ /**
+ * Literal arguments prepended before the typed arguments. Lets one
+ * dispatcher script back several entrypoints — e.g. a fixed method
+ * name as {@code argv[1]} with the typed JSON payload as {@code argv[2]}.
+ */
+ @Builder.Default
+ private List fixedArgs = List.of();
+ /**
+ * Raw JSON Schema object describing the entrypoint's parameters,
+ * forwarded verbatim as the wrapper tool's input schema.
+ */
+ @Builder.Default
+ private Map parameters = Map.of();
+ /**
+ * How typed arguments reach the script process:
+ * {@code json} (default) forwards one compact JSON argument;
+ * {@code flags} forwards each property as a {@code --key value} pair.
+ */
+ @Builder.Default
+ private String argStyle = "json";
+ }
+
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_EMPTY)
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java
index ce401207..c0defeaf 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java
@@ -40,6 +40,7 @@ public class SkillManifestParser {
"dashboard", "self-evolution", "self_evolution",
"knowledge",
"acp",
+ "scripts",
// legacy / housekeeping fields that aren't manifest-relevant
"metadata"
);
@@ -102,6 +103,7 @@ public class SkillManifestParser {
.selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution")))
.knowledge(parseKnowledge(fm.get("knowledge")))
.acp(parseAcp(fm.get("acp")))
+ .scripts(parseScripts(fm.get("scripts")))
.extras(extractUnknown(fm));
return b.build();
@@ -261,6 +263,37 @@ public class SkillManifestParser {
.build();
}
+ // ==================== scripts ====================
+
+ /**
+ * Parse the {@code scripts} block — a list of script entrypoint maps.
+ * The per-entry {@code parameters} map is carried through as a raw
+ * JSON Schema object; nested maps / lists from the YAML parse stay
+ * intact so the wrapper factory can serialize them verbatim.
+ */
+ @SuppressWarnings("unchecked")
+ private List parseScripts(Object rawScripts) {
+ if (!(rawScripts instanceof List> list)) return List.of();
+ List out = new ArrayList<>();
+ for (Object item : list) {
+ if (!(item instanceof Map, ?> map)) continue;
+ Map m = (Map) map;
+ Map parameters = m.get("parameters") instanceof Map, ?> p
+ ? toStringObjectMap((Map, ?>) p) : Map.of();
+ out.add(SkillManifest.ScriptDef.builder()
+ .id(string(m, "id"))
+ .label(string(m, "label"))
+ .path(string(m, "path"))
+ .description(string(m, "description"))
+ .fixedArgs(stringList(coalesce(m, "fixed_args", "fixedArgs")))
+ .parameters(parameters)
+ .argStyle(stringOrDefault(m, "arg_style",
+ stringOrDefault(m, "argStyle", "json")))
+ .build());
+ }
+ return out;
+ }
+
@SuppressWarnings("unchecked")
private SkillManifest.AcpBinding parseAcp(Object raw) {
if (!(raw instanceof Map, ?> map)) return null;
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java
index 17766a9d..5e383232 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java
@@ -44,11 +44,27 @@ import java.util.Map;
* links back to the MCP page).
*
*
- * ID namespace: virtual skill ids use a high sentinel
- * {@link #VIRTUAL_ID_BASE} + mcpServerId so they can never collide
- * with real {@code mate_skill.id} values (Snowflake longs are bounded
- * well below this base). Negative numbers were considered but several
- * existing endpoints {@code abs()} the id for path constraints.
+ *
ID namespace: virtual ids encode a 2-bit type tag in the top two
+ * bits of a {@code long}, leaving 62 bits to carry the underlying
+ * mcpServerId:
+ *
+ * bit 63 (sign) | bit 62 | bits 0..61
+ * --------------+--------+--------------------------------
+ * 0 | 0 | real persisted skill (Snowflake)
+ * 1 | 0 | virtual MCP-derived skill
+ * 1 | 1 | virtual ACP-derived skill
+ *
+ *
+ * The earlier {@code 9e18 + serverId} addition scheme broke once
+ * Snowflake-issued mcpServerIds crossed ~{@code 2e18} (the sum then
+ * overflowed signed long, wrapping to a negative number that no longer
+ * satisfied {@code id >= 9e18} — every detail / lookup of a freshly
+ * created MCP server 500'd with "技能不存在"). The bit-tagged layout
+ * has no arithmetic and survives any 62-bit server id.
+ *
+ *
The constants are arranged so that {@code BASE + smallId} still
+ * equals {@code BASE | smallId} for any {@code smallId < 2^62}, so
+ * test fixtures that build virtual ids by addition keep working.
*/
@Slf4j
@Service
@@ -56,53 +72,85 @@ import java.util.Map;
public class McpSkillBridge {
/**
- * High sentinel for virtual id space. Snowflake ids fit in 63 bits
- * but in practice never approach this magnitude, so anything
- * {@code >= VIRTUAL_ID_BASE} is unambiguously a bridged MCP skill.
+ * Type tag for the MCP virtual id space: bit 63 set, bit 62 clear.
+ * Equal to {@link Long#MIN_VALUE}; named for the historical
+ * "base sentinel" idiom callers still use.
*/
- public static final long VIRTUAL_ID_BASE = 9_000_000_000_000_000_000L;
+ public static final long VIRTUAL_ID_BASE = Long.MIN_VALUE; // 0x8000000000000000L
+
+ /** Selects the top-two type-tag bits. */
+ private static final long TAG_MASK = 0xC000000000000000L;
+ /** Selects the bottom 62 bits that carry the original server id. */
+ private static final long ID_MASK = 0x3FFFFFFFFFFFFFFFL;
private final McpServerService mcpServerService;
private final McpClientManager mcpClientManager;
private final ObjectMapper objectMapper;
/**
- * @return true iff the given id falls inside the virtual MCP skill
- * range. Cheap O(1) check, callers use it to route lookups
- * between the real DB and this bridge.
+ * @return true iff the given id carries the MCP virtual-skill type
+ * tag (bit 63 set, bit 62 clear). Cheap O(1) bit-mask check.
*/
public static boolean isVirtualMcpSkillId(Long id) {
- return id != null && id >= VIRTUAL_ID_BASE;
+ return id != null && (id & TAG_MASK) == VIRTUAL_ID_BASE;
}
/** Inverse mapping: extract the original MCP server id. */
public static Long extractMcpServerId(Long virtualId) {
if (!isVirtualMcpSkillId(virtualId)) return null;
- return virtualId - VIRTUAL_ID_BASE;
+ return virtualId & ID_MASK;
}
public static long virtualIdFor(McpServerEntity server) {
- return VIRTUAL_ID_BASE + server.getId();
+ long sid = server.getId();
+ if ((sid & TAG_MASK) != 0L) {
+ throw new IllegalStateException(
+ "MCP server id 0x" + Long.toHexString(sid)
+ + " uses the top two bits — would collide with the virtual id type tag");
+ }
+ return VIRTUAL_ID_BASE | sid;
}
/**
- * Snapshot every enabled MCP server as a virtual {@link SkillEntity}.
- * Used by the Skills list endpoint; rows are non-persistent and
- * regenerated on each call.
+ * Snapshot every MCP server as a virtual {@link SkillEntity}. Used by
+ * the Skills list endpoint; rows are non-persistent and regenerated on
+ * each call. Disabled servers are included so a skill the user toggled
+ * off still shows on the Skills page (as a disabled card) and can be
+ * toggled back on — {@code enabled} mirrors the server's flag.
*/
public List listMcpDerivedSkillEntities() {
- return listEnabledServers().stream().map(this::serverToEntity).toList();
+ return listAllServers().stream().map(this::serverToEntity).toList();
}
/**
- * Snapshot every enabled MCP server as a virtual {@link ResolvedSkill}
- * with synthesized manifest, ready to be merged into the runtime
- * status feed. Status reflects connection health: OK → READY default
- * feature; ERROR / disconnected → SETUP_NEEDED with a diagnostic
- * missing-dependency entry.
+ * Snapshot every MCP server as a virtual {@link ResolvedSkill} with
+ * synthesized manifest, ready to be merged into the runtime status
+ * feed. Status reflects connection health: OK → READY default feature;
+ * ERROR / disconnected → SETUP_NEEDED with a diagnostic missing-dependency
+ * entry. Disabled servers are included for the admin status view; the
+ * active-skill gate ({@code SkillRuntimeService.passesActiveGate}) keeps
+ * them out of the agent runtime.
*/
public List listMcpDerivedResolvedSkills() {
- return listEnabledServers().stream().map(this::serverToResolved).toList();
+ return listAllServers().stream().map(this::serverToResolved).toList();
+ }
+
+ /**
+ * Enable or disable the MCP server behind a virtual MCP skill.
+ *
+ * A virtual MCP skill has no {@code mate_skill} row — its enabled
+ * state is the underlying MCP server's {@code enabled} flag. Toggling
+ * the skill therefore toggles the server, which also connects or
+ * disconnects it. Returns the rebuilt virtual {@link SkillEntity}
+ * reflecting the new state.
+ */
+ public SkillEntity toggleVirtualSkill(Long virtualId, boolean enabled) {
+ Long serverId = extractMcpServerId(virtualId);
+ if (serverId == null) {
+ throw new IllegalArgumentException("Not a virtual MCP skill id: " + virtualId);
+ }
+ McpServerEntity updated = mcpServerService.toggle(serverId, enabled);
+ return serverToEntity(updated);
}
/**
@@ -121,19 +169,20 @@ public class McpSkillBridge {
}
}
- private List listEnabledServers() {
+ private List listAllServers() {
try {
- return mcpServerService.listEnabled();
+ return mcpServerService.listAll();
} catch (Exception e) {
- log.warn("MCP bridge could not list enabled servers: {}", e.getMessage());
+ log.warn("MCP bridge could not list servers: {}", e.getMessage());
return List.of();
}
}
private SkillEntity serverToEntity(McpServerEntity server) {
+ List tools = readToolDescriptors(server);
SkillEntity s = new SkillEntity();
s.setId(virtualIdFor(server));
- s.setName(slugify(server.getName()));
+ s.setName(slugForServer(server));
s.setNameEn(displayName(server));
s.setNameZh(displayName(server));
s.setDescription(buildDescription(server));
@@ -145,13 +194,15 @@ public class McpSkillBridge {
s.setBuiltin(false);
s.setTags("mcp");
s.setSecurityScanStatus("PASSED"); // MCP servers don't go through SkillSecurityService
+ s.setSkillContent(buildSkillContent(server, tools));
s.setConfigJson(buildConfigJson(server));
- s.setManifestJson(serializeManifest(buildManifestFrom(server, readToolRawNames(server))));
+ s.setManifestJson(serializeManifest(buildManifestFrom(server, toRawNames(tools))));
return s;
}
private ResolvedSkill serverToResolved(McpServerEntity server) {
- List rawNames = readToolRawNames(server);
+ List tools = readToolDescriptors(server);
+ List rawNames = toRawNames(tools);
Map toolDisplayNames = new LinkedHashMap<>();
for (String raw : rawNames) {
String prefixed = McpToolNameResolver.prefixedName(server.getId(), raw);
@@ -175,9 +226,9 @@ public class McpSkillBridge {
return ResolvedSkill.builder()
.id(virtualIdFor(server))
- .name(slugify(server.getName()))
+ .name(slugForServer(server))
.description(buildDescription(server))
- .content("") // no SKILL.md
+ .content(buildSkillContent(server, tools))
.source("mcp")
.skillDir(null)
.configuredSkillDir(null)
@@ -242,9 +293,10 @@ public class McpSkillBridge {
.description("MCP server '" + server.getName() + "' must be connected. Configure in Settings ▸ MCP Connections.")
.build();
+ String slug = slugForServer(server);
return SkillManifest.builder()
- .id(slugify(server.getName()))
- .name(slugify(server.getName()))
+ .id(slug)
+ .name(slug)
.description(buildDescription(server))
.icon(iconFor(server))
.version("1.0.0")
@@ -266,24 +318,27 @@ public class McpSkillBridge {
}
/**
- * Resolve the raw tool name list for a server with cache-first / live-fallback
- * semantics. Returns an empty list (never null) so the manifest builder
- * stays simple.
+ * Resolve the tool list for a server with cache-first / live-fallback
+ * semantics. Each entry carries the raw name and (when available) the
+ * upstream description. Returns an empty list (never null) so callers
+ * stay simple.
*/
- private List readToolRawNames(McpServerEntity server) {
- List fromCache = parseCachedToolNames(server.getToolsCacheJson());
+ private List readToolDescriptors(McpServerEntity server) {
+ List fromCache = parseCachedToolDescriptors(server.getToolsCacheJson());
if (!fromCache.isEmpty()) {
return fromCache;
}
try {
List discovered = mcpClientManager.getServerTools(server.getId());
- List names = new ArrayList<>(discovered.size());
+ List out = new ArrayList<>(discovered.size());
for (McpSchema.Tool t : discovered) {
if (t == null) continue;
String n = t.name();
- if (n != null && !n.isBlank()) names.add(n);
+ if (n != null && !n.isBlank()) {
+ out.add(new McpToolDescriptor(n, t.description()));
+ }
}
- return names;
+ return out;
} catch (Exception e) {
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
server.getId(), e.getMessage());
@@ -293,22 +348,25 @@ public class McpSkillBridge {
/**
* Parse the {@code tools_cache_json} column written by
- * {@code McpServerService} after each successful connect. Returns an
- * empty list if the column is null/blank/malformed — the bridge is
- * required to keep working when the cache hasn't been populated yet
- * (e.g. first-ever connect just succeeded a moment ago).
+ * {@code McpServerService} after each successful connect — an array of
+ * {@code {name, description, inputSchema}} entries. Returns an empty
+ * list if the column is null/blank/malformed — the bridge is required
+ * to keep working when the cache hasn't been populated yet (e.g.
+ * first-ever connect just succeeded a moment ago).
*/
- private List parseCachedToolNames(String json) {
+ private List parseCachedToolDescriptors(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json);
- List out = new ArrayList<>(arr.size());
+ List out = new ArrayList<>(arr.size());
for (Object obj : arr) {
if (!(obj instanceof cn.hutool.json.JSONObject jo)) continue;
String name = jo.getStr("name");
- if (name != null && !name.isBlank()) out.add(name);
+ if (name != null && !name.isBlank()) {
+ out.add(new McpToolDescriptor(name, jo.getStr("description")));
+ }
}
return out;
} catch (Exception e) {
@@ -317,11 +375,106 @@ public class McpSkillBridge {
}
}
+ private static List toRawNames(List tools) {
+ List names = new ArrayList<>(tools.size());
+ for (McpToolDescriptor t : tools) {
+ names.add(t.name());
+ }
+ return names;
+ }
+
+ /**
+ * Synthesize a SKILL.md body for an MCP-derived virtual skill.
+ *
+ * Persisted and uploaded skills ship a hand-written SKILL.md that the
+ * agent serves on demand through {@code readSkillFile}; it tells the
+ * model what the skill is for and how to drive it. An MCP-derived skill
+ * has no such file — the upstream server only exposes a tool list — so
+ * without a synthesized body {@code readSkillFile} returns "content not
+ * available" and the model has nothing beyond the one-line description
+ * to reason about.
+ *
+ *
This builds an equivalent body from the live tool snapshot: a
+ * one-line summary, the tool catalog with per-tool descriptions, and a
+ * short usage note. Regenerated on every list call, so it tracks the
+ * upstream tool set with no persistence step.
+ */
+ private String buildSkillContent(McpServerEntity server, List tools) {
+ String displayName = displayName(server);
+ StringBuilder md = new StringBuilder();
+ md.append("# ").append(displayName).append("\n\n");
+ md.append(buildDescription(server)).append("\n\n");
+ md.append("This capability is provided by the MCP server **").append(displayName).append("**");
+ String transport = nullSafe(server.getTransport());
+ if (!transport.isBlank()) {
+ md.append(" (").append(transport).append(" transport)");
+ }
+ md.append(". Its tools are available to you as ordinary function calls — ")
+ .append("invoke them directly by name; no shell or scripts are involved.\n\n");
+
+ md.append("## Available Tools\n\n");
+ if (tools.isEmpty()) {
+ md.append("The tool list is not available yet. The MCP server may be ")
+ .append("disconnected or still starting up — check its status in ")
+ .append("Settings ▸ MCP Connections.\n");
+ return md.toString();
+ }
+ md.append("This server exposes ").append(tools.size())
+ .append(tools.size() == 1 ? " tool:\n\n" : " tools:\n\n");
+ for (McpToolDescriptor t : tools) {
+ md.append("- **").append(t.name()).append("**");
+ String desc = oneLine(t.description());
+ if (!desc.isBlank()) {
+ md.append(" — ").append(desc);
+ }
+ md.append("\n");
+ }
+ md.append("\n## Usage Notes\n\n");
+ md.append("- These tools appear in your tool list under `mcp_`-prefixed names; ")
+ .append("pick whichever one matches the user's request.\n");
+ md.append("- If a call fails with a connection error, the MCP server is likely ")
+ .append("disconnected — it can be reconnected in Settings ▸ MCP Connections.\n");
+ return md.toString();
+ }
+
+ /** Collapse whitespace and clamp a tool description to a prompt-friendly length. */
+ private static String oneLine(String s) {
+ if (s == null) {
+ return "";
+ }
+ String collapsed = s.replaceAll("\\s+", " ").trim();
+ return collapsed.length() > 200 ? collapsed.substring(0, 200) + "…" : collapsed;
+ }
+
+ /** Minimal MCP tool projection: just what the manifest and SKILL.md body need. */
+ private record McpToolDescriptor(String name, String description) {}
+
private String slugify(String raw) {
if (raw == null) return "";
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
}
+ /**
+ * Stable slug for an MCP server. Falls back to {@code mcp-{id}} when
+ * the source name has no ASCII letter/digit (e.g. pure CJK), because
+ * the naive slugify would otherwise return a run of dashes — making
+ * two differently-named all-CJK servers collide on the same display
+ * key and breaking name-based skill lookup.
+ */
+ private String slugForServer(McpServerEntity server) {
+ String slug = slugify(server.getName());
+ return hasAsciiAlphaNumeric(slug) ? slug : "mcp-" + server.getId();
+ }
+
+ private static boolean hasAsciiAlphaNumeric(String s) {
+ if (s == null || s.isEmpty()) return false;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) return true;
+ }
+ return false;
+ }
+
private String displayName(McpServerEntity server) {
return server.getName() != null ? server.getName() : "mcp-" + server.getId();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
index 44c29730..9ab88d18 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
@@ -130,6 +130,26 @@ public class SkillEntity {
/** RFC-042 §2.3 — wall-clock time of the last scan write-back. */
private LocalDateTime securityScanTime;
+ /**
+ * Lifecycle state for the time-window archival state machine:
+ * {@code active} / {@code stale} / {@code archived}. Defaults to
+ * {@code active} via the column DEFAULT.
+ */
+ private String lifecycleState;
+
+ /** User-pinned skill — exempt from automatic archival. */
+ private Boolean pinned;
+
+ /**
+ * Activity anchor, cached from {@code mate_skill_usage_stat.last_loaded_at}
+ * so the daily lifecycle sweep is a single indexed select instead of a
+ * join. {@code null} falls back to {@code createTime} as the anchor.
+ */
+ private LocalDateTime lastActivityAt;
+
+ /** Wall-clock time the skill entered the archived state. */
+ private LocalDateTime archivedAt;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java
new file mode 100644
index 00000000..fa590407
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java
@@ -0,0 +1,262 @@
+package vip.mate.skill.runtime;
+
+import cn.hutool.json.JSONUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.stereotype.Component;
+import vip.mate.skill.knowledge.SkillScopedToolCallback;
+import vip.mate.skill.manifest.SkillManifest;
+import vip.mate.skill.runtime.model.ResolvedSkill;
+import vip.mate.skill.secret.SkillSecretService;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Wrapper tool factory for skill script entrypoints declared in the
+ * {@code scripts} manifest block.
+ *
+ * A directory-backed skill ships executable scripts under {@code scripts/}.
+ * The generic {@code runSkillScript} tool can run any of them, but it forces
+ * the model to hand-assemble the argument list — brittle whenever a script
+ * consumes a structured JSON payload. This factory turns each declared
+ * entrypoint into its own typed tool: the model fills schema-described
+ * fields, and the runtime serializes them into process arguments. The model
+ * never crafts a JSON string by hand.
+ *
+ *
One wrapper per entrypoint, named {@code skill__}.
+ * Each wrapper closes over the resolved skill directory and id, so a call
+ * always targets the declaring skill's own script and decrypted secrets and
+ * cannot be redirected elsewhere.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ScriptSkillWrapperToolFactory {
+
+ private final SkillScriptExecutionService executionService;
+ private final SkillFileAccessPolicy accessPolicy;
+ private final SkillSecretService skillSecretService;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Build one wrapper callback per declared script entrypoint. Returns an
+ * empty list when the skill declares no entrypoints or has no directory
+ * (a database-only skill cannot expose runnable scripts).
+ */
+ public List buildWrappers(ResolvedSkill resolved, SkillManifest manifest) {
+ if (resolved == null || manifest == null
+ || manifest.getScripts() == null || manifest.getScripts().isEmpty()
+ || resolved.getSkillDir() == null) {
+ return List.of();
+ }
+ String skillSlug = sanitize(manifest.getName());
+ if (skillSlug.isBlank()) {
+ return List.of();
+ }
+ Path skillDir = resolved.getSkillDir();
+ Long skillId = resolved.getId();
+
+ List out = new ArrayList<>();
+ Set seen = new LinkedHashSet<>();
+ for (SkillManifest.ScriptDef def : manifest.getScripts()) {
+ if (!isUsable(def)) {
+ continue;
+ }
+ String name = "skill_" + skillSlug + "_" + sanitize(def.getId());
+ if (!seen.add(name)) {
+ log.warn("Skill '{}' script entrypoint id '{}' collides on tool name '{}'; skipping duplicate",
+ manifest.getName(), def.getId(), name);
+ continue;
+ }
+ out.add(new SkillScopedToolCallback(
+ name,
+ buildDescription(manifest, def),
+ buildInputSchema(def),
+ input -> invoke(skillDir, skillId, def, input)));
+ }
+ return out;
+ }
+
+ /**
+ * Names the wrappers this manifest would produce, without building them.
+ * Used by the resolver to merge entrypoint names into
+ * {@code manifest.allowedTools} so {@code getEffectiveAllowedTools()}
+ * surfaces them like any other declared tool.
+ */
+ public List wrapperNames(SkillManifest manifest) {
+ if (manifest == null || manifest.getName() == null
+ || manifest.getScripts() == null || manifest.getScripts().isEmpty()) {
+ return List.of();
+ }
+ String skillSlug = sanitize(manifest.getName());
+ if (skillSlug.isBlank()) {
+ return List.of();
+ }
+ List names = new ArrayList<>();
+ Set seen = new LinkedHashSet<>();
+ for (SkillManifest.ScriptDef def : manifest.getScripts()) {
+ if (!isUsable(def)) {
+ continue;
+ }
+ String name = "skill_" + skillSlug + "_" + sanitize(def.getId());
+ if (seen.add(name)) {
+ names.add(name);
+ }
+ }
+ return names;
+ }
+
+ /** An entrypoint is usable only when it has both an id and a script path. */
+ private static boolean isUsable(SkillManifest.ScriptDef def) {
+ return def != null
+ && def.getId() != null && !def.getId().isBlank()
+ && def.getPath() != null && !def.getPath().isBlank();
+ }
+
+ // ==================== invocation ====================
+
+ private String invoke(Path skillDir, Long skillId, SkillManifest.ScriptDef def, String input) {
+ try {
+ JsonNode args = (input == null || input.isBlank())
+ ? objectMapper.createObjectNode()
+ : objectMapper.readTree(input);
+
+ // Path traversal is blocked here — only scripts under the
+ // skill's own scripts/ directory can be reached.
+ Path scriptPath = accessPolicy.validateScriptPath(skillDir, def.getPath());
+ if (scriptPath == null) {
+ return errorJson("invalid or unsafe script path: " + def.getPath());
+ }
+
+ List argv = buildArgv(def.getFixedArgs(), def.getArgStyle(), args);
+
+ // Inject this skill's stored secrets as subprocess env vars,
+ // mirroring the generic runSkillScript path.
+ Map envVars = skillId != null
+ ? skillSecretService.getDecrypted(skillId)
+ : Map.of();
+
+ SkillScriptExecutionService.ScriptResult result =
+ executionService.execute(scriptPath, argv, envVars);
+ return JSONUtil.createObj()
+ .set("exitCode", result.getExitCode())
+ .set("stdout", result.getStdout())
+ .set("stderr", result.getStderr())
+ .toString();
+ } catch (Exception e) {
+ log.warn("script wrapper for entrypoint '{}' failed: {}", def.getId(), e.getMessage());
+ return errorJson(e.getMessage() == null ? "script invocation failed" : e.getMessage());
+ }
+ }
+
+ /**
+ * Translate the typed argument object into a process argument list.
+ *
+ * {@code fixedArgs} are emitted first, verbatim — they let one
+ * dispatcher script back several entrypoints (e.g. a fixed method name
+ * as {@code argv[1]}). The typed arguments follow, shaped by
+ * {@code argStyle}:
+ *
+ *
+ * {@code json} (default) — append the whole object as one compact
+ * JSON argument, the shape a script reading its last argv with a
+ * JSON parser expects.
+ * {@code flags} — append each property as {@code --key value};
+ * a {@code true} boolean becomes a bare {@code --key}, while a
+ * {@code false} or null property is dropped.
+ *
+ *
+ * Package-private and static for direct unit testing.
+ *
+ * @return the argument list, or {@code null} when it would be empty
+ */
+ static List buildArgv(List fixedArgs, String argStyle, JsonNode args) {
+ List out = new ArrayList<>();
+ if (fixedArgs != null) {
+ for (String fixed : fixedArgs) {
+ if (fixed != null) {
+ out.add(fixed);
+ }
+ }
+ }
+ if ("flags".equalsIgnoreCase(argStyle)) {
+ if (args != null && args.isObject()) {
+ Iterator> fields = args.fields();
+ while (fields.hasNext()) {
+ Map.Entry field = fields.next();
+ JsonNode value = field.getValue();
+ if (value == null || value.isNull()) {
+ continue;
+ }
+ if (value.isBoolean()) {
+ if (value.asBoolean()) {
+ out.add("--" + field.getKey());
+ }
+ continue;
+ }
+ out.add("--" + field.getKey());
+ out.add(value.isValueNode() ? value.asText() : value.toString());
+ }
+ }
+ } else {
+ // Default: json — append one compact JSON argument, unless the
+ // object is empty / absent (an entrypoint with no typed input).
+ if (args != null && !args.isNull() && !args.isMissingNode()
+ && !(args.isObject() && args.isEmpty())) {
+ out.add(args.toString());
+ }
+ }
+ return out.isEmpty() ? null : out;
+ }
+
+ // ==================== helpers ====================
+
+ private String buildDescription(SkillManifest manifest, SkillManifest.ScriptDef def) {
+ String base;
+ if (def.getDescription() != null && !def.getDescription().isBlank()) {
+ base = def.getDescription().trim();
+ } else if (def.getLabel() != null && !def.getLabel().isBlank()) {
+ base = def.getLabel().trim();
+ } else {
+ base = "Run the '" + def.getId() + "' script";
+ }
+ return base + " (skill: " + manifest.getName() + "). "
+ + "Fill the described fields; the arguments are forwarded to the script for you.";
+ }
+
+ private String buildInputSchema(SkillManifest.ScriptDef def) {
+ Map params = def.getParameters();
+ if (params == null || params.isEmpty()) {
+ return "{\"type\":\"object\",\"properties\":{}}";
+ }
+ try {
+ return objectMapper.writeValueAsString(params);
+ } catch (Exception e) {
+ log.warn("script entrypoint '{}' has an unserializable parameter schema: {}",
+ def.getId(), e.getMessage());
+ return "{\"type\":\"object\",\"properties\":{}}";
+ }
+ }
+
+ /** Tool-name slug rule shared with the knowledge / acp wrapper factories. */
+ private static String sanitize(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_");
+ }
+
+ private static String errorJson(String message) {
+ return JSONUtil.createObj().set("error", message).toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java
new file mode 100644
index 00000000..1a587fed
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java
@@ -0,0 +1,28 @@
+package vip.mate.skill.runtime;
+
+import java.util.Set;
+
+/**
+ * Renders the agent-scoped skill catalog segment at runtime so its ordering can
+ * react to skills loaded during the current graph run.
+ *
+ * Built once per agent (capturing the agent's bound skills, effective tool
+ * allowlist, model window and workspace), then invoked each turn by the
+ * reasoning / step-execution nodes with the set of skills already loaded this
+ * run. Loaded skills are pinned to the top of the catalog so a multi-iteration
+ * loop stops re-loading something it already pulled into message history.
+ */
+@FunctionalInterface
+public interface SkillCatalogRenderer {
+
+ /**
+ * Render the {@code ## Skills} catalog segment.
+ *
+ * @param loadedThisRun skill names loaded via {@code load_skill} so far in
+ * this run; pinned to the top of the catalog. Never
+ * {@code null} — pass an empty set when nothing loaded.
+ * @return the catalog markdown, or an empty string when the agent has no
+ * visible skills.
+ */
+ String render(Set loadedThisRun);
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java
index d8e850a2..8fa19c5e 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java
@@ -7,7 +7,7 @@ import java.nio.file.Path;
/**
* 技能文件访问策略
- * 确保只能访问 skillDir 内的 references/ 和 scripts/ 文件
+ * 确保只能访问 skillDir 内的 references/、scripts/ 和 templates/ 文件
*/
@Slf4j
@Component
@@ -17,7 +17,7 @@ public class SkillFileAccessPolicy {
* 验证文件路径是否安全
*
* @param skillDir 技能根目录
- * @param relativePath 相对路径(必须以 references/ 或 scripts/ 开头)
+ * @param relativePath 相对路径(必须以 references/、scripts/ 或 templates/ 开头)
* @return 归一化后的绝对路径,如果不安全则返回 null
*/
public Path validateAndResolve(Path skillDir, String relativePath) {
@@ -28,8 +28,10 @@ public class SkillFileAccessPolicy {
// 归一化路径分隔符
String normalized = relativePath.replace("\\", "/");
- // 必须以 references/ 或 scripts/ 开头
- if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) {
+ // 必须以 references/、scripts/ 或 templates/ 开头
+ if (!normalized.startsWith("references/")
+ && !normalized.startsWith("scripts/")
+ && !normalized.startsWith("templates/")) {
log.warn("Invalid path prefix: {}", relativePath);
return null;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
index d6ee4115..25903b0b 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
@@ -72,6 +72,12 @@ public class SkillPackageResolver {
* Spring lifecycle.
*/
private final AcpSkillWrapperToolFactory acpWrapperFactory;
+ /**
+ * Script-entrypoint wrapper factory for skills that declare a
+ * {@code scripts} manifest block. {@code @Lazy} to match the sibling
+ * wrapper factories and stay clear of bean construction-order loops.
+ */
+ private final ScriptSkillWrapperToolFactory scriptWrapperFactory;
/**
* {@code @Lazy} on ToolRegistry — same lazy-resolution loop as
* {@code SkillDependencyChecker}; without this we'd reach for the
@@ -109,6 +115,7 @@ public class SkillPackageResolver {
SkillMapper skillMapper,
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
+ @Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory,
@Lazy ToolRegistry toolRegistry) {
this.frontmatterParser = frontmatterParser;
this.manifestParser = manifestParser;
@@ -120,6 +127,7 @@ public class SkillPackageResolver {
this.skillMapper = skillMapper;
this.wikiWrapperFactory = wikiWrapperFactory;
this.acpWrapperFactory = acpWrapperFactory;
+ this.scriptWrapperFactory = scriptWrapperFactory;
this.toolRegistry = toolRegistry;
}
@@ -335,6 +343,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
+ .workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime())
.build();
}
@@ -376,6 +385,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
+ .workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime())
.build();
}
@@ -531,6 +541,11 @@ public class SkillPackageResolver {
// registeredWrappers map so deregistration covers both.
applyAcpWrappers(resolved, manifest);
+ // Skills that declare a scripts[] block get one typed wrapper
+ // tool per entrypoint, so a script consuming structured input
+ // is driven by schema fields instead of a hand-built JSON arg.
+ applyScriptWrappers(resolved, manifest);
+
// Build requirement lookup for feature checks.
Map reqByKey = new LinkedHashMap<>();
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
@@ -806,6 +821,58 @@ public class SkillPackageResolver {
manifest.setAllowedTools(mergedAllowed);
}
+ /**
+ * Register typed wrapper tools for a skill's declared script entrypoints
+ * (the {@code scripts} manifest block). Parallel structure to
+ * {@link #applyKnowledgeWrappers} / {@link #applyAcpWrappers}.
+ *
+ * Skipped for {@code knowledge} / {@code acp} skills: those types own
+ * the wrapper slot, and this method must never deregister wrappers a
+ * sibling branch just registered. Also skipped when the skill declares
+ * no entrypoints, is disabled, or has no directory.
+ */
+ private void applyScriptWrappers(ResolvedSkill resolved, SkillManifest manifest) {
+ String type = manifest.getType();
+ if ("knowledge".equalsIgnoreCase(type) || "acp".equalsIgnoreCase(type)) {
+ return;
+ }
+ boolean hasScripts = manifest.getScripts() != null && !manifest.getScripts().isEmpty();
+ if (!hasScripts || !resolved.isEnabled() || resolved.getSkillDir() == null) {
+ return;
+ }
+ // Fresh build so a re-resolve diff-updates the registry cleanly.
+ // applyKnowledgeWrappers already cleared any prior set for a
+ // non-knowledge skill; this is a no-op safety net.
+ deregisterSkillWrappers(resolved.getId());
+
+ java.util.List wrappers = scriptWrapperFactory.buildWrappers(resolved, manifest);
+ if (wrappers.isEmpty()) {
+ return;
+ }
+ java.util.Set registered = new java.util.LinkedHashSet<>();
+ Long entityId = resolved.getId();
+ for (ToolCallback cb : wrappers) {
+ String name = cb.getToolDefinition().name();
+ registered.add(name);
+ toolRegistry.registerPluginTool(cb, () ->
+ entityId != null && resolved.isEnabled());
+ }
+ if (entityId != null) {
+ registeredWrappers.put(entityId, registered);
+ }
+
+ // Append wrapper names to allowedTools so getEffectiveAllowedTools
+ // surfaces them like any other manifest-declared tool.
+ java.util.List mergedAllowed = new java.util.ArrayList<>(
+ manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools());
+ for (String wrapperName : scriptWrapperFactory.wrapperNames(manifest)) {
+ if (!mergedAllowed.contains(wrapperName)) {
+ mergedAllowed.add(wrapperName);
+ }
+ }
+ manifest.setAllowedTools(mergedAllowed);
+ }
+
// ==================== 阶段 4:综合判定 ====================
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
index 008d4553..edfa3eca 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
@@ -63,6 +63,16 @@ public class SkillRuntimeService {
private final AcpSkillBridge acpSkillBridge;
private final SkillUsageService usageService;
+ /**
+ * Mirrors {@code mateclaw.skill.disclosure.load-skill-tool.enabled}. When
+ * false the catalog guidance points at {@code readSkillFile} instead of
+ * {@code load_skill} (which is also unregistered upstream). Field-initialised
+ * to true so non-Spring unit construction keeps the default behavior.
+ */
+ @org.springframework.beans.factory.annotation.Value(
+ "${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
+ private boolean loadSkillToolEnabled = true;
+
@Autowired
public SkillRuntimeService(SkillService skillService,
SkillPackageResolver packageResolver,
@@ -345,13 +355,48 @@ public class SkillRuntimeService {
public String buildSkillPromptEnhancement(Set boundSkillIds,
Set effectiveToolNames,
Integer maxInputTokens) {
- return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null);
+ return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null);
}
public String buildSkillPromptEnhancement(Set boundSkillIds,
Set effectiveToolNames,
Integer maxInputTokens,
Long agentId) {
+ return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, agentId, null);
+ }
+
+ /**
+ * 构建技能目录提示片段(支持按 Agent 工作区隔离)。
+ *
+ * @param agentWorkspaceId 调用 Agent 的工作区 ID。非 null 时,目录只保留
+ * 内置技能(全局)与该工作区拥有的技能;其他工作区
+ * 的技能不会注入 prompt。null 表示不做工作区过滤
+ * (调试预览等全局场景)。
+ */
+ public String buildSkillPromptEnhancement(Set boundSkillIds,
+ Set effectiveToolNames,
+ Integer maxInputTokens,
+ Long agentId,
+ Long agentWorkspaceId) {
+ return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens,
+ agentId, agentWorkspaceId, Set.of());
+ }
+
+ /**
+ * Build the skill catalog prompt segment, pinning skills loaded this run to
+ * the top so a multi-iteration loop stops re-loading the same skill.
+ *
+ * @param loadedThisRunNames names of skills loaded via {@code load_skill}
+ * during the current graph run; sorted to the top
+ * of the catalog ahead of the usage-history
+ * signals. Never {@code null}.
+ */
+ public String buildSkillPromptEnhancement(Set boundSkillIds,
+ Set effectiveToolNames,
+ Integer maxInputTokens,
+ Long agentId,
+ Long agentWorkspaceId,
+ Set loadedThisRunNames) {
List activeSkills;
if (boundSkillIds != null) {
// Per-agent filter: pick the agent's bound subset from the
@@ -381,6 +426,16 @@ public class SkillRuntimeService {
activeSkills = activeSkills.stream()
.filter(s -> matchesCurrentPlatform(s, currentOs))
.collect(java.util.stream.Collectors.toList());
+ // Workspace filter — a workspace-B agent must not see workspace-A's
+ // skills in its catalog. Builtin skills are global; virtual MCP
+ // skills carry no workspace (null) and stay globally visible. Only
+ // applied when the caller supplies the agent's workspace; the debug
+ // preview passes null to keep its global view.
+ if (agentWorkspaceId != null) {
+ activeSkills = activeSkills.stream()
+ .filter(s -> matchesWorkspace(s, agentWorkspaceId))
+ .collect(java.util.stream.Collectors.toList());
+ }
if (activeSkills.isEmpty()) {
return "";
}
@@ -401,14 +456,10 @@ public class SkillRuntimeService {
// hide new skills behind 40+ existing ones, and the LLM tells the
// user "no such skill" minutes after they uploaded it.
java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now().minus(NEW_SKILL_BOOST_WINDOW);
- List sorted = SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED)
- .stream()
- .sorted(java.util.Comparator
- .comparingInt((ResolvedSkill s) -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1)
- .thenComparingInt(s -> recentNames.contains(s.getName()) ? 0 : 1)
- .thenComparingInt(s -> frequentNames.contains(s.getName()) ? 0 : 1)
- .thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED)))
- .toList();
+ Set loadedNames = loadedThisRunNames == null ? Set.of() : loadedThisRunNames;
+ List sorted = applyCatalogSignals(
+ SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED),
+ loadedNames, recentNames, frequentNames, recencyCutoff);
List pinned = sorted.stream()
.filter(s -> s.getId() != null && boundIds.contains(s.getId()))
.toList();
@@ -422,15 +473,29 @@ public class SkillRuntimeService {
StringBuilder sb = new StringBuilder();
sb.append("\n\n## Skills\n");
sb.append("This is a compact catalog. If a listed skill matches the task, ");
- sb.append("first call `readSkillFile(skillName=, filePath=\"SKILL.md\")` and follow its instructions. ");
+ if (loadSkillToolEnabled) {
+ sb.append("first call `load_skill(skillName=)` to pull its SKILL.md into the conversation, ");
+ sb.append("then follow its instructions. Once loaded, the skill stays available in the conversation — ");
+ sb.append("do not load it again. ");
+ } else {
+ sb.append("first call `readSkillFile(skillName=, filePath=\"SKILL.md\")` to read its instructions, ");
+ sb.append("then follow them. ");
+ }
sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog ");
sb.append("(it accepts `keyword=` and `limit=` up to 50 — use them to search by topic ");
sb.append("when the default page is truncated). ");
sb.append("If the user names a specific skill that isn't in this table, ");
- sb.append("call `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly — ");
+ if (loadSkillToolEnabled) {
+ sb.append("call `load_skill(skillName=\"\")` directly — ");
+ } else {
+ sb.append("call `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly — ");
+ }
sb.append("the catalog above is intentionally compact and doesn't list every active skill. ");
sb.append("Skills are documentation packages — calling a skill name as a tool will fail. ");
- sb.append("Skills with a `scripts/` directory expose `runSkillScript`; SKILL.md will name the script when needed.\n\n");
+ sb.append("To read a skill's reference or script files, use ");
+ sb.append("`readSkillFile(skillName=, filePath=\"references/...\")`. ");
+ sb.append("Skills with a `scripts/` directory expose `runSkillScript`; ");
+ sb.append("SKILL.md will name the script when needed.\n\n");
sb.append("| Skill | Status | Description |\n");
sb.append("|-------|--------|-------------|\n");
for (ResolvedSkill skill : selected) {
@@ -464,6 +529,33 @@ public class SkillRuntimeService {
return sb.toString();
}
+ /**
+ * Apply the catalog ranking signals on top of the RECOMMENDED base order.
+ * Priority, highest first: loaded this run, freshly installed, recently
+ * loaded (DB history), frequently loaded (DB history), then the RECOMMENDED
+ * comparator as the stable tiebreak.
+ *
+ * Package-private and static so it can be unit-tested without standing up
+ * the full service.
+ */
+ static List applyCatalogSignals(List recommended,
+ Set loadedThisRunNames,
+ Set recentNames,
+ Set frequentNames,
+ java.time.LocalDateTime recencyCutoff) {
+ Set loaded = loadedThisRunNames == null ? Set.of() : loadedThisRunNames;
+ Set recent = recentNames == null ? Set.of() : recentNames;
+ Set frequent = frequentNames == null ? Set.of() : frequentNames;
+ return recommended.stream()
+ .sorted(java.util.Comparator
+ .comparingInt((ResolvedSkill s) -> loaded.contains(s.getName()) ? 0 : 1)
+ .thenComparingInt(s -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1)
+ .thenComparingInt(s -> recent.contains(s.getName()) ? 0 : 1)
+ .thenComparingInt(s -> frequent.contains(s.getName()) ? 0 : 1)
+ .thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED)))
+ .toList();
+ }
+
private static boolean isVisibleWithTools(ResolvedSkill skill, Set effectiveToolNames) {
if (effectiveToolNames == null) return true;
Set tools = skill.getEffectiveAllowedTools();
@@ -576,4 +668,17 @@ public class SkillRuntimeService {
}
return false;
}
+
+ /**
+ * True when the skill is visible to an agent in {@code agentWorkspaceId}.
+ * Builtin skills are global, virtual MCP-derived skills carry no
+ * workspace ({@code null}) and are likewise global; every other skill is
+ * visible only inside its owning workspace.
+ */
+ static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) {
+ if (skill.isBuiltin()) return true;
+ Long skillWs = skill.getWorkspaceId();
+ if (skillWs == null) return true;
+ return skillWs == agentWorkspaceId;
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java
index b6fc9793..94922ac6 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java
@@ -121,7 +121,87 @@ public class SkillSecurityService {
"(?i)import\\s+(os|subprocess|shutil)",
"Python system module import",
"Importing os/subprocess/shutil enables system-level operations",
- "Ensure system operations are necessary and scoped appropriately")
+ "Ensure system operations are necessary and scoped appropriately"),
+
+ // ===== Python:不可信反序列化 / 沙箱逃逸 =====
+ rule("PICKLE_DESERIALIZE", "DESERIALIZATION", SkillValidationResult.Severity.HIGH,
+ "(?i)\\b(c?pickle|dill)\\.loads?\\s*\\(",
+ "Untrusted deserialization (pickle)",
+ "pickle/dill load executes arbitrary code embedded in the payload",
+ "Use json or a vetted serializer; never unpickle untrusted data"),
+ rule("MARSHAL_LOADS", "DESERIALIZATION", SkillValidationResult.Severity.HIGH,
+ "(?i)\\bmarshal\\.loads?\\s*\\(",
+ "Untrusted deserialization (marshal)",
+ "marshal can execute crafted bytecode",
+ "Avoid marshal for external data"),
+ // MEDIUM (warn, not block): yaml.load is only unsafe WITHOUT SafeLoader,
+ // and the line-by-line scan can't see a SafeLoader argument that wraps
+ // onto the next line — so blocking here would false-positive legit
+ // multi-line safe calls. Surface it for review instead of blocking.
+ rule("YAML_UNSAFE_LOAD", "DESERIALIZATION", SkillValidationResult.Severity.MEDIUM,
+ "(?i)\\byaml\\.load\\s*\\((?![^)]*(?i:safe))",
+ "Possibly unsafe yaml.load",
+ "yaml.load without SafeLoader can instantiate arbitrary Python objects",
+ "Use yaml.safe_load or Loader=yaml.SafeLoader"),
+ rule("PY_SANDBOX_ESCAPE", "SANDBOX_ESCAPE", SkillValidationResult.Severity.HIGH,
+ "(__subclasses__|__mro__|__builtins__|__globals__)",
+ "Python sandbox-escape primitive",
+ "Introspection attributes commonly used to break out of restricted execution",
+ "Remove reflection into builtins / class hierarchies"),
+ rule("PY_CTYPES", "CODE_EXECUTION", SkillValidationResult.Severity.HIGH,
+ "(?i)\\bctypes\\.(cdll|windll)\\b",
+ "Native library loading via ctypes",
+ "ctypes can load and call arbitrary native code",
+ "Avoid ctypes; use safe Python APIs"),
+ rule("PY_DYNAMIC_IMPORT", "CODE_EXECUTION", SkillValidationResult.Severity.LOW,
+ "(?i)(\\b__import__\\s*\\(|\\bimportlib\\.import_module\\s*\\()",
+ "Dynamic module import",
+ "Dynamic imports can load attacker-controlled modules",
+ "Import modules statically by name where possible"),
+
+ // ===== Node.js:危险 API =====
+ rule("NODE_CHILD_PROCESS", "CODE_EXECUTION", SkillValidationResult.Severity.MEDIUM,
+ "(?i)(require\\s*\\(\\s*['\"]child_process['\"]\\s*\\)|child_process\\.(exec|execSync|spawn|spawnSync|fork))",
+ "Node child_process execution",
+ "child_process can run arbitrary system commands",
+ "Confirm subprocess use is necessary and arguments are not attacker-controlled"),
+ rule("NODE_NEW_FUNCTION", "CODE_EXECUTION", SkillValidationResult.Severity.HIGH,
+ "(?i)\\bnew\\s+Function\\s*\\(",
+ "Dynamic code execution (new Function)",
+ "new Function compiles strings into executable code, like eval",
+ "Use structured logic instead of constructing functions from strings"),
+ rule("NODE_VM_MODULE", "CODE_EXECUTION", SkillValidationResult.Severity.MEDIUM,
+ "(?i)require\\s*\\(\\s*['\"](vm|vm2)['\"]\\s*\\)",
+ "Node vm/vm2 module",
+ "vm/vm2 are frequently used (and escaped) for sandboxed eval",
+ "Avoid the vm module for untrusted code"),
+ rule("PROTOTYPE_POLLUTION", "SANDBOX_ESCAPE", SkillValidationResult.Severity.MEDIUM,
+ "(\\[\\s*['\"]__proto__['\"]\\s*\\]|\\.__proto__\\s*=)",
+ "Prototype pollution pattern",
+ "Writing __proto__ can corrupt object prototypes platform-wide",
+ "Validate keys before dynamic property assignment"),
+
+ // ===== 混淆 / 资源耗尽 / 持久化 / 凭据读取 =====
+ rule("BASE64_PIPE_EXEC", "OBFUSCATION", SkillValidationResult.Severity.HIGH,
+ "(?i)base64\\s+(-d|--decode)\\b[^\\n|]*\\|\\s*(sh|bash|zsh|python|perl|node)\\b",
+ "Obfuscated execution (base64 decode piped to interpreter)",
+ "Decoding then piping to a shell hides the real command from review",
+ "Ship the command in clear text"),
+ rule("FORK_BOMB", "RESOURCE_EXHAUSTION", SkillValidationResult.Severity.CRITICAL,
+ ":\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:\\s*&\\s*\\}\\s*;\\s*:",
+ "Fork bomb",
+ "Self-replicating process that exhausts system resources",
+ "Remove the fork bomb"),
+ rule("PERSISTENCE", "PERSISTENCE", SkillValidationResult.Severity.MEDIUM,
+ "(?i)(crontab\\s+-|/etc/cron|authorized_keys|/etc/rc\\.local|/etc/profile\\.d/|>>\\s*~?/?\\.?(bashrc|zshrc|profile))",
+ "Persistence mechanism",
+ "Modifies startup files / cron / SSH keys to persist across sessions",
+ "Skills should not install persistence hooks"),
+ rule("SECRET_FILE_READ", "DATA_EXFILTRATION", SkillValidationResult.Severity.HIGH,
+ "(?i)(/etc/shadow|\\.ssh/id_(rsa|ed25519|ecdsa)|\\.aws/credentials|\\.kube/config)",
+ "Sensitive credential file access",
+ "References well-known secret files (private keys, cloud credentials)",
+ "Skills must not read system or user credential files")
);
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java
index 6df78e20..eda94028 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java
@@ -73,6 +73,14 @@ public class ResolvedSkill {
@Builder.Default
private boolean builtin = false;
+ /**
+ * Owning workspace, copied from {@code mate_skill.workspace_id}. Builtin
+ * skills are global, so for them this is informational only. {@code null}
+ * for virtual MCP-derived skills (MCP servers carry no workspace) — the
+ * runtime treats a null workspace as globally visible.
+ */
+ private Long workspaceId;
+
/**
* Skill row create timestamp, copied from {@code mate_skill.create_time}.
* Used by the prompt-catalog ranker to surface freshly installed skills
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java b/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java
index 3d4761d3..9335f643 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
+import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
import java.util.Map;
@@ -36,12 +37,14 @@ public class SkillSecretController {
@Operation(summary = "List secret keys + masked previews for a skill")
@GetMapping
+ @RequireWorkspaceRole("admin")
public R> list(@PathVariable Long skillId) {
return R.ok(skillSecretService.listSummaries(skillId));
}
@Operation(summary = "Upsert a secret value (empty value deletes it)")
@PostMapping
+ @RequireWorkspaceRole("admin")
public R put(@PathVariable Long skillId, @RequestBody Map body) {
skillSecretService.put(skillId, body.get("key"), body.get("value"));
return R.ok();
@@ -49,6 +52,7 @@ public class SkillSecretController {
@Operation(summary = "Delete a single secret by key")
@DeleteMapping("/{key}")
+ @RequireWorkspaceRole("admin")
public R remove(@PathVariable Long skillId, @PathVariable String key) {
skillSecretService.remove(skillId, key);
return R.ok();
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java
index f20b8612..69f17d40 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java
@@ -5,8 +5,11 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.exception.MateClawException;
+import vip.mate.skill.event.SkillRemovedEvent;
+import vip.mate.skill.lifecycle.SkillLifecycleService;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillFileMapper;
import vip.mate.skill.repository.SkillMapper;
@@ -47,6 +50,15 @@ public class SkillService {
private final SkillWorkspaceManager workspaceManager;
private final SkillWorkspaceProperties workspaceProperties;
private final SkillSecretService skillSecretService;
+ /**
+ * Fires {@link SkillRemovedEvent} on both uninstall and hard-delete so
+ * the agent-binding listener (and any future subscriber) can scrub
+ * dependent rows — without this, {@code mate_agent_skill} keeps orphan
+ * rows that the UI can no longer clear from the picker.
+ */
+ private final ApplicationEventPublisher eventPublisher;
+ /** Stamps the activity anchor on create / update / enable so the curator sees fresh skills as active. */
+ private final SkillLifecycleService lifecycleService;
private vip.mate.skill.runtime.SkillRuntimeService runtimeService;
/**
@@ -58,6 +70,25 @@ public class SkillService {
// ==================== CRUD ====================
+ /** Default workspace id used when no {@code X-Workspace-Id} is supplied. */
+ public static final long DEFAULT_WORKSPACE_ID = 1L;
+
+ static long normalizeWorkspaceId(Long workspaceId) {
+ return workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
+ }
+
+ /**
+ * Restrict a query to skills visible inside {@code workspaceId}: builtin
+ * skills are global (shared across every workspace), every other skill is
+ * owned by exactly one workspace. Applied as a nested {@code AND (builtin
+ * OR workspace_id = ?)} group so it composes with other filters.
+ */
+ private static void applyWorkspaceScope(LambdaQueryWrapper wrapper, Long workspaceId) {
+ long wsId = normalizeWorkspaceId(workspaceId);
+ wrapper.and(w -> w.eq(SkillEntity::getBuiltin, true)
+ .or().eq(SkillEntity::getWorkspaceId, wsId));
+ }
+
/**
* 获取所有技能列表(管理页面使用)
* 排序:内置优先,然后按创建时间倒序
@@ -68,6 +99,18 @@ public class SkillService {
.orderByDesc(SkillEntity::getCreateTime));
}
+ /**
+ * Workspace-scoped variant of {@link #listSkills()} — returns builtin
+ * skills plus the skills owned by {@code workspaceId}.
+ */
+ public List listSkills(Long workspaceId) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .orderByDesc(SkillEntity::getBuiltin)
+ .orderByDesc(SkillEntity::getCreateTime);
+ applyWorkspaceScope(wrapper, workspaceId);
+ return skillMapper.selectList(wrapper);
+ }
+
/**
* Paginated skill listing for the SkillMarket admin UI.
*
@@ -79,33 +122,24 @@ public class SkillService {
* security_scan_status}: {@code "FAILED"} surfaces blocked skills so the
* admin can inspect findings and rescan, {@code "PASSED"} shows scanned
* clean rows, {@code null} / empty means no scan filter.
+ *
+ * {@code workspaceId} scopes the result to one workspace's catalog:
+ * builtin skills are always included (they're global), every other skill
+ * only when it belongs to {@code workspaceId}. A {@code null} workspace
+ * falls back to the default workspace.
*/
- public IPage pageSkills(int page, int size, String keyword,
- String skillType, Boolean enabled,
- String scanStatus) {
- return pageSkills(page, size, keyword, skillType, enabled, scanStatus,
- null, null, null, Set.of());
- }
-
- public IPage