From 323ba1b82ea655f851cdd002ef82c3af3ceed7b1 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 1 May 2026 09:48:32 +0800 Subject: [PATCH] feat(skill): manifest schema + parser (additive) --- .../mate/skill/manifest/SkillManifest.java | 192 ++++++++++ .../skill/manifest/SkillManifestParser.java | 340 ++++++++++++++++++ .../vip/mate/skill/model/SkillEntity.java | 9 + .../migration/h2/V67__add_skill_manifest.sql | 6 + .../mysql/V67__add_skill_manifest.sql | 8 + 5 files changed, 555 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V67__add_skill_manifest.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V67__add_skill_manifest.sql 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 new file mode 100644 index 00000000..aee6d099 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java @@ -0,0 +1,192 @@ +package vip.mate.skill.manifest; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * RFC-090 Phase 2 — parsed SKILL.md manifest (source of truth, §14.6). + * + *

Maps the YAML frontmatter shape from §5.1 onto a typed model. + * Persisted to {@code mate_skill.manifest_json}; legacy columns + * (skill_type / icon / version / author) are projected from this + * after each resolve. + * + *

All collection fields default to empty so consumers don't need + * null guards. Unknown YAML keys are preserved in + * {@link #extras} for forward compatibility. + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class SkillManifest { + + // ==================== Identity ==================== + + private String id; + private String name; + private String description; + private String icon; + private String version; + private String author; + + /** prompt | code | mcp | acp | knowledge */ + private String type; + + /** file | web | data | content | comm | system | ... (free-form tag) */ + private String category; + + // ==================== Tools / dependencies ==================== + + /** Anthropic-compatible {@code allowed-tools} list. */ + @Builder.Default + private List allowedTools = List.of(); + + /** Top-level {@code requires} entries. */ + @Builder.Default + private List requires = List.of(); + + /** Top-level {@code platforms} list (overall package compatibility). */ + @Builder.Default + private List platforms = List.of(); + + /** + * v3.1 {@code features[]} matrix. Empty list means + * "no explicit feature partitioning" — the resolver synthesizes a + * single default feature carrying the top-level {@link #requires} + * and {@link #platforms} so legacy skills behave unchanged. + */ + @Builder.Default + private List features = List.of(); + + // ==================== User-facing settings ==================== + + @Builder.Default + private List settings = List.of(); + + // ==================== Provider routing ==================== + + @Builder.Default + private List requiresModel = List.of(); + + // ==================== Dashboard ==================== + + @Builder.Default + private List dashboardMetrics = List.of(); + + // ==================== v3 self-evolution ==================== + + @Builder.Default + private SelfEvolution selfEvolution = SelfEvolution.defaults(); + + // ==================== v3.1 knowledge type ==================== + + private KnowledgeBinding knowledge; + + // ==================== Forward-compat catch-all ==================== + + /** Unknown frontmatter keys are stashed here so a future field + * doesn't drop on parse — the JSON round-trips intact. */ + @Builder.Default + private Map extras = Map.of(); + + // ==================== Nested types ==================== + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class RequirementDef { + /** Required: stable identifier referenced by {@code features[*].requires}. */ + private String key; + /** binary | env_var | api_key */ + private String type; + /** Probe target — for binary, the executable name; for env_var, the env name. */ + private String check; + /** Optional means it only blocks features that reference it explicitly. */ + @Builder.Default + private boolean optional = false; + private String description; + @Builder.Default + private Map install = Map.of(); + } + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class FeatureDef { + private String id; + private String label; + @Builder.Default + private List requires = List.of(); + @Builder.Default + private List platforms = List.of(); + /** Tools advertised only when this feature is READY. Empty + * means "inherit the manifest-level allowed-tools as-is". */ + @Builder.Default + private List tools = List.of(); + private String fallbackMessage; + private String unsupportedMessage; + } + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class SettingDef { + private String key; + private String label; + /** select | text | secret | toggle */ + private String type; + private Object defaultValue; + @Builder.Default + private List> options = List.of(); + } + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class DashboardMetric { + private String label; + private String memoryKey; + private String format; + } + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class SelfEvolution { + @Builder.Default + private boolean lessonsEnabled = true; + @Builder.Default + private int lessonsMaxEntries = 50; + @Builder.Default + private boolean memoryWritesAllowed = true; + + public static SelfEvolution defaults() { + return SelfEvolution.builder().build(); + } + } + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class KnowledgeBinding { + /** KB slug declared in manifest (e.g. {@code tcm-classics}). */ + private String bindKb; + /** vector | bm25 | hybrid */ + private String retrieval; + @Builder.Default + private int topK = 6; + /** required | optional | none */ + @Builder.Default + private String citation = "optional"; + @Builder.Default + private boolean rerank = false; + + /** Resolved KB id, written at install time after slug lookup + * (RFC-090 §14.4). Null until resolved. */ + private Long boundKbId; + } +} 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 new file mode 100644 index 00000000..b6539cf0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java @@ -0,0 +1,340 @@ +package vip.mate.skill.manifest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * RFC-090 Phase 2 — typed parser for SKILL.md manifests. + * + *

Builds a {@link SkillManifest} from the raw frontmatter map produced + * by {@link SkillFrontmatterParser}. Unknown keys are stashed in + * {@link SkillManifest#getExtras()} so we can JSON round-trip safely. + * + *

This is additive: it does not modify {@code SkillFrontmatterParser} + * and does not change runtime behavior on its own. Callers wire in via + * {@link SkillPackageResolver}. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillManifestParser { + + private static final Set KNOWN_KEYS = Set.of( + "id", "name", "description", "icon", "version", "author", + "type", "category", + "allowed-tools", "allowed_tools", + "requires", "platforms", "features", + "settings", "requires-model", "requires_model", + "dependencies", + "dashboard", "self-evolution", "self_evolution", + "knowledge", + // legacy / housekeeping fields that aren't manifest-relevant + "metadata" + ); + + private final SkillFrontmatterParser frontmatterParser; + + /** + * Parse the SKILL.md content into a typed manifest. + * + *

Returns {@code null} when the content has no frontmatter or + * fails to parse — callers should fall back to legacy handling. + */ + public SkillManifest parse(String content) { + if (content == null || content.isBlank()) { + return null; + } + SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content); + return parseFromFrontmatter(parsed); + } + + /** + * Build a manifest from an already-parsed frontmatter object so we + * don't re-run the YAML parse when the resolver already has the + * map handy. + */ + public SkillManifest parseFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed) { + if (parsed == null || parsed.getFrontmatter() == null) { + return null; + } + Map fm = parsed.getFrontmatter(); + + SkillManifest.SkillManifestBuilder b = SkillManifest.builder() + .id(string(fm, "id")) + .name(string(fm, "name")) + .description(string(fm, "description")) + .icon(string(fm, "icon")) + .version(string(fm, "version")) + .author(string(fm, "author")) + .type(string(fm, "type")) + .category(string(fm, "category")) + .allowedTools(stringList(coalesce(fm, "allowed-tools", "allowed_tools"))) + .platforms(parsed.getPlatforms() == null ? List.of() : parsed.getPlatforms()) + .requires(parseRequires(fm.get("requires"), parsed.getDependencies())) + .features(parseFeatures(fm.get("features"))) + .settings(parseSettings(fm.get("settings"))) + .requiresModel(stringList(coalesce(fm, "requires-model", "requires_model"))) + .dashboardMetrics(parseDashboard(fm.get("dashboard"))) + .selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution"))) + .knowledge(parseKnowledge(fm.get("knowledge"))) + .extras(extractUnknown(fm)); + + return b.build(); + } + + // ==================== requires ==================== + + @SuppressWarnings("unchecked") + private List parseRequires( + Object rawRequires, SkillFrontmatterParser.SkillDependencies legacyDeps) { + List out = new ArrayList<>(); + + if (rawRequires instanceof List list) { + for (Object item : list) { + if (!(item instanceof Map map)) continue; + Map m = (Map) map; + Map install = m.get("install") instanceof Map i + ? toStringObjectMap((Map) i) : Map.of(); + Map installStr = new LinkedHashMap<>(); + for (Map.Entry e : install.entrySet()) { + if (e.getValue() != null) installStr.put(e.getKey(), e.getValue().toString()); + } + out.add(SkillManifest.RequirementDef.builder() + .key(string(m, "key")) + .type(string(m, "type")) + .check(string(m, "check")) + .optional(bool(m, "optional", false)) + .description(string(m, "description")) + .install(installStr) + .build()); + } + } + + // Backward compat: synthesize requires from legacy + // dependencies.{commands,env} when no v3 requires[] block is present. + if (out.isEmpty() && legacyDeps != null) { + for (String cmd : legacyDeps.getCommands()) { + out.add(SkillManifest.RequirementDef.builder() + .key("cmd:" + cmd) + .type("binary") + .check(cmd) + .build()); + } + for (String env : legacyDeps.getEnv()) { + out.add(SkillManifest.RequirementDef.builder() + .key("env:" + env) + .type("env_var") + .check(env) + .build()); + } + } + return out; + } + + // ==================== features ==================== + + @SuppressWarnings("unchecked") + private List parseFeatures(Object rawFeatures) { + if (!(rawFeatures instanceof List list)) return List.of(); + List out = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) continue; + Map m = (Map) map; + out.add(SkillManifest.FeatureDef.builder() + .id(string(m, "id")) + .label(string(m, "label")) + .requires(stringList(m.get("requires"))) + .platforms(stringList(m.get("platforms"))) + .tools(stringList(m.get("tools"))) + .fallbackMessage(string(m, "fallback_message")) + .unsupportedMessage(string(m, "unsupported_message")) + .build()); + } + return out; + } + + // ==================== settings ==================== + + @SuppressWarnings("unchecked") + private List parseSettings(Object rawSettings) { + if (!(rawSettings instanceof List list)) return List.of(); + List out = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) continue; + Map m = (Map) map; + List> options = new ArrayList<>(); + if (m.get("options") instanceof List opts) { + for (Object opt : opts) { + if (opt instanceof Map om) { + options.add(toStringObjectMap((Map) om)); + } + } + } + out.add(SkillManifest.SettingDef.builder() + .key(string(m, "key")) + .label(string(m, "label")) + .type(string(m, "type")) + .defaultValue(m.get("default")) + .options(options) + .build()); + } + return out; + } + + // ==================== dashboard ==================== + + @SuppressWarnings("unchecked") + private List parseDashboard(Object rawDashboard) { + if (!(rawDashboard instanceof Map dashMap)) return List.of(); + Object metrics = ((Map) dashMap).get("metrics"); + if (!(metrics instanceof List list)) return List.of(); + List out = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) continue; + Map m = (Map) map; + out.add(SkillManifest.DashboardMetric.builder() + .label(string(m, "label")) + .memoryKey(string(m, "memory_key")) + .format(string(m, "format")) + .build()); + } + return out; + } + + // ==================== self-evolution ==================== + + @SuppressWarnings("unchecked") + private SkillManifest.SelfEvolution parseSelfEvolution(Object raw) { + if (!(raw instanceof Map map)) return SkillManifest.SelfEvolution.defaults(); + Map m = (Map) map; + return SkillManifest.SelfEvolution.builder() + .lessonsEnabled(bool(m, "lessons_enabled", true)) + .lessonsMaxEntries(intVal(m, "lessons_max_entries", 50)) + .memoryWritesAllowed(bool(m, "memory_writes_allowed", true)) + .build(); + } + + // ==================== knowledge ==================== + + @SuppressWarnings("unchecked") + private SkillManifest.KnowledgeBinding parseKnowledge(Object raw) { + if (!(raw instanceof Map map)) return null; + Map m = (Map) map; + Object kbId = m.get("boundKbId"); + Long resolvedId = null; + if (kbId instanceof Number n) resolvedId = n.longValue(); + else if (kbId instanceof String s && !s.isBlank()) { + try { resolvedId = Long.parseLong(s.trim()); } catch (NumberFormatException ignored) { /* leave null */ } + } + return SkillManifest.KnowledgeBinding.builder() + .bindKb(string(m, "bind_kb")) + .retrieval(string(m, "retrieval")) + .topK(intVal(m, "top_k", 6)) + .citation(stringOrDefault(m, "citation", "optional")) + .rerank(bool(m, "rerank", false)) + .boundKbId(resolvedId) + .build(); + } + + // ==================== helpers ==================== + + private Map extractUnknown(Map fm) { + Map extras = new LinkedHashMap<>(); + for (Map.Entry e : fm.entrySet()) { + if (!KNOWN_KEYS.contains(e.getKey())) extras.put(e.getKey(), e.getValue()); + } + return extras.isEmpty() ? Map.of() : extras; + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v == null ? null : v.toString(); + } + + private static String stringOrDefault(Map map, String key, String fallback) { + String v = string(map, key); + return v == null || v.isBlank() ? fallback : v; + } + + private static boolean bool(Map map, String key, boolean fallback) { + Object v = map.get(key); + if (v == null) return fallback; + if (v instanceof Boolean b) return b; + return Boolean.parseBoolean(v.toString()); + } + + private static int intVal(Map map, String key, int fallback) { + Object v = map.get(key); + if (v == null) return fallback; + if (v instanceof Number n) return n.intValue(); + try { return Integer.parseInt(v.toString().trim()); } catch (NumberFormatException e) { return fallback; } + } + + @SuppressWarnings("unchecked") + private static List stringList(Object v) { + if (v == null) return List.of(); + if (v instanceof List list) { + List out = new ArrayList<>(list.size()); + for (Object item : list) { + if (item != null) out.add(item.toString()); + } + return out; + } + if (v instanceof String s && !s.isBlank()) return List.of(s); + return List.of(); + } + + @SuppressWarnings("unchecked") + private static Map toStringObjectMap(Map raw) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : raw.entrySet()) { + if (e.getKey() != null) out.put(e.getKey().toString(), e.getValue()); + } + return out; + } + + /** Pick the first non-null value among the listed keys. */ + private static Object coalesce(Map fm, String... keys) { + for (String k : keys) { + Object v = fm.get(k); + if (v != null) return v; + } + return null; + } + + /** + * Convenience accessor for downstream code that wants a quick set of + * required requirement keys (not yet status-aware). + */ + public static Set requiredKeys(SkillManifest manifest) { + if (manifest == null) return Collections.emptySet(); + Set keys = new HashSet<>(); + for (SkillManifest.RequirementDef r : manifest.getRequires()) { + if (r.getKey() != null) keys.add(r.getKey()); + } + return keys; + } + + /** Hook mirror for {@link #parseFromFrontmatter} when callers only have a raw map. */ + public SkillManifest parseRawMap(Map frontmatter, List platforms, + SkillFrontmatterParser.SkillDependencies deps) { + if (frontmatter == null) return null; + SkillFrontmatterParser.ParsedSkillMd shim = SkillFrontmatterParser.ParsedSkillMd.builder() + .frontmatter(new HashMap<>(frontmatter)) + .platforms(platforms == null ? List.of() : platforms) + .dependencies(deps == null ? SkillFrontmatterParser.SkillDependencies.empty() : deps) + .build(); + return parseFromFrontmatter(shim); + } +} 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 5fcc5fe8..0199066a 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 @@ -79,6 +79,15 @@ public class SkillEntity { @TableField(value = "skill_content", updateStrategy = FieldStrategy.ALWAYS) private String skillContent; + /** + * RFC-090 Phase 2 — full parsed SKILL.md frontmatter as JSON. + * Source of truth (§14.6); existing columns (skill_type/icon/version/ + * author) become index projections written by + * {@code SkillPackageResolver} after each resolve. + */ + @TableField(value = "manifest_json", updateStrategy = FieldStrategy.ALWAYS) + private String manifestJson; + /** 是否启用 */ private Boolean enabled; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V67__add_skill_manifest.sql b/mateclaw-server/src/main/resources/db/migration/h2/V67__add_skill_manifest.sql new file mode 100644 index 00000000..3542dd45 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V67__add_skill_manifest.sql @@ -0,0 +1,6 @@ +-- V67: Skill manifest_json column (RFC-090 Phase 2) +-- Stores the full parsed SKILL.md frontmatter as JSON. This becomes the +-- source of truth (RFC-090 §14.6); existing columns (skill_type, icon, +-- version, author) are kept as index projections, written by +-- SkillPackageResolver.projectManifestToColumns after each resolve. +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS manifest_json LONGTEXT; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V67__add_skill_manifest.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V67__add_skill_manifest.sql new file mode 100644 index 00000000..bcb2972c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V67__add_skill_manifest.sql @@ -0,0 +1,8 @@ +-- V67: Skill manifest_json column (RFC-090 Phase 2) +-- Stores the full parsed SKILL.md frontmatter as JSON. This becomes the +-- source of truth (RFC-090 §14.6); existing columns (skill_type, icon, +-- version, author) are kept as index projections, written by +-- SkillPackageResolver.projectManifestToColumns after each resolve. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_skill' AND COLUMN_NAME = 'manifest_json'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_skill ADD COLUMN manifest_json LONGTEXT', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;