feat(skill): manifest schema + parser (additive)

This commit is contained in:
matevip 2026-05-01 09:48:32 +08:00
parent 95144ef175
commit 323ba1b82e
5 changed files with 555 additions and 0 deletions

View File

@ -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).
*
* <p>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.
*
* <p>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<String> allowedTools = List.of();
/** Top-level {@code requires} entries. */
@Builder.Default
private List<RequirementDef> requires = List.of();
/** Top-level {@code platforms} list (overall package compatibility). */
@Builder.Default
private List<String> 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<FeatureDef> features = List.of();
// ==================== User-facing settings ====================
@Builder.Default
private List<SettingDef> settings = List.of();
// ==================== Provider routing ====================
@Builder.Default
private List<String> requiresModel = List.of();
// ==================== Dashboard ====================
@Builder.Default
private List<DashboardMetric> 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<String, Object> 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<String, String> install = Map.of();
}
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public static class FeatureDef {
private String id;
private String label;
@Builder.Default
private List<String> requires = List.of();
@Builder.Default
private List<String> 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<String> 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<Map<String, Object>> 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;
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<String> 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.
*
* <p>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<String, Object> 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<SkillManifest.RequirementDef> parseRequires(
Object rawRequires, SkillFrontmatterParser.SkillDependencies legacyDeps) {
List<SkillManifest.RequirementDef> out = new ArrayList<>();
if (rawRequires instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> map)) continue;
Map<String, Object> m = (Map<String, Object>) map;
Map<String, Object> install = m.get("install") instanceof Map<?, ?> i
? toStringObjectMap((Map<?, ?>) i) : Map.of();
Map<String, String> installStr = new LinkedHashMap<>();
for (Map.Entry<String, Object> 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<SkillManifest.FeatureDef> parseFeatures(Object rawFeatures) {
if (!(rawFeatures instanceof List<?> list)) return List.of();
List<SkillManifest.FeatureDef> out = new ArrayList<>();
for (Object item : list) {
if (!(item instanceof Map<?, ?> map)) continue;
Map<String, Object> m = (Map<String, Object>) 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<SkillManifest.SettingDef> parseSettings(Object rawSettings) {
if (!(rawSettings instanceof List<?> list)) return List.of();
List<SkillManifest.SettingDef> out = new ArrayList<>();
for (Object item : list) {
if (!(item instanceof Map<?, ?> map)) continue;
Map<String, Object> m = (Map<String, Object>) map;
List<Map<String, Object>> 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<SkillManifest.DashboardMetric> parseDashboard(Object rawDashboard) {
if (!(rawDashboard instanceof Map<?, ?> dashMap)) return List.of();
Object metrics = ((Map<String, Object>) dashMap).get("metrics");
if (!(metrics instanceof List<?> list)) return List.of();
List<SkillManifest.DashboardMetric> out = new ArrayList<>();
for (Object item : list) {
if (!(item instanceof Map<?, ?> map)) continue;
Map<String, Object> m = (Map<String, Object>) 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<String, Object> m = (Map<String, Object>) 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<String, Object> m = (Map<String, Object>) 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<String, Object> extractUnknown(Map<String, Object> fm) {
Map<String, Object> extras = new LinkedHashMap<>();
for (Map.Entry<String, Object> 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<String, Object> map, String key) {
Object v = map.get(key);
return v == null ? null : v.toString();
}
private static String stringOrDefault(Map<String, Object> map, String key, String fallback) {
String v = string(map, key);
return v == null || v.isBlank() ? fallback : v;
}
private static boolean bool(Map<String, Object> 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<String, Object> 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<String> stringList(Object v) {
if (v == null) return List.of();
if (v instanceof List<?> list) {
List<String> 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<String, Object> toStringObjectMap(Map<?, ?> raw) {
Map<String, Object> 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<String, Object> 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<String> requiredKeys(SkillManifest manifest) {
if (manifest == null) return Collections.emptySet();
Set<String> 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<String, Object> frontmatter, List<String> 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);
}
}

View File

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

View File

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

View File

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