From ba167e09f4a02586366fb0aa92cf3467aba12834 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 00:12:39 +0800 Subject: [PATCH] perf(skill): startup snapshot + builtin scan skip + prompt slim-down --- .../installer/BuiltinSkillSeedService.java | 176 ++++++++++++++++++ .../skill/runtime/SkillPackageResolver.java | 63 ++++++- .../skill/runtime/SkillRuntimeService.java | 171 ++++++++++++----- 3 files changed, 360 insertions(+), 50 deletions(-) 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 f951f64d..61f3446b 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 @@ -1,9 +1,11 @@ package vip.mate.skill.installer; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; @@ -15,13 +17,19 @@ import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillFrontmatterParser; +import java.io.IOException; import java.io.InputStream; +import java.net.URLConnection; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.TreeMap; /** * Builtin skill seed service — RFC-044 §4.2. @@ -58,10 +66,19 @@ public class BuiltinSkillSeedService implements ApplicationRunner { private static final String DEFAULT_VERSION = "1.0.0"; private static final String SKILL_TYPE_BUILTIN = "builtin"; + /** Snapshot version — bump on any schema change inside the JSON. */ + private static final int SNAPSHOT_VERSION = 1; + private final SkillMapper skillMapper; private final SkillFrontmatterParser frontmatterParser; private final ObjectMapper objectMapper; + /** Workspace root, used as the parent of the snapshot file. Mirrors + * {@code SkillWorkspaceProperties#root} so we don't drag the whole + * properties bean in for one path lookup. */ + @Value("${mateclaw.skill.workspace.root:#{systemProperties['user.home'] + '/.mateclaw/skills'}}") + private String workspaceRoot; + @Override public void run(ApplicationArguments args) { try { @@ -82,6 +99,22 @@ public class BuiltinSkillSeedService implements ApplicationRunner { return new SyncStats(0, 0, 0, 0); } + // Fast path — if every SKILL.md's (size, mtime) matches the snapshot + // 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. + Map currentManifest = buildResourceManifest(resources); + SeedSnapshot snapshot = loadSnapshot(); + if (snapshot != null + && snapshot.version == SNAPSHOT_VERSION + && manifestEquals(snapshot.manifest, currentManifest) + && countBuiltinRows() == snapshot.rowCount) { + log.info("[SkillSeed] Manifest unchanged ({} skills); skipping per-row resolve", + currentManifest.size()); + return new SyncStats(0, 0, currentManifest.size(), 0); + } + int inserted = 0, updated = 0, unchanged = 0, skipped = 0; for (Resource resource : resources) { try { @@ -117,9 +150,152 @@ public class BuiltinSkillSeedService implements ApplicationRunner { } log.info("[SkillSeed] Builtin skills: {} inserted, {} updated, {} unchanged, {} skipped", inserted, updated, unchanged, skipped); + // Persist the manifest so the next startup can take the fast path + // when nothing changed. Failure to write is non-fatal — worst case + // the next startup re-runs the full loop. + writeSnapshot(new SeedSnapshot(SNAPSHOT_VERSION, currentManifest, countBuiltinRows())); return new SyncStats(inserted, updated, unchanged, skipped); } + // ==================== Snapshot helpers ==================== + + /** Snapshot persisted at {workspace_root}/.builtin-seed-snapshot.json. */ + private record SeedSnapshot(int version, Map manifest, long rowCount) {} + + private Path snapshotPath() { + return Paths.get(workspaceRoot).resolve(".builtin-seed-snapshot.json"); + } + + /** + * Build a stable manifest of {@code uri → [size, mtime]} for the + * shipped SKILL.md set. {@link TreeMap} keeps the iteration order + * deterministic so byte-equality of two manifests means the same + * thing across runs. + * + *

{@link Resource#contentLength()} works in both exploded-classpath + * dev mode and inside a JAR. {@link URLConnection#getLastModified()} + * returns the JAR's mtime when the resource lives inside one — that + * still gives us a useful invalidation signal: rebuilding the JAR + * shifts every entry's mtime in lockstep, busting the snapshot. + */ + private Map buildResourceManifest(Resource[] resources) { + Map out = new TreeMap<>(); + for (Resource res : resources) { + try { + String key = res.getURI().toString(); + long size = res.contentLength(); + long mtime; + try { + URLConnection conn = res.getURL().openConnection(); + mtime = conn.getLastModified(); + } catch (IOException ignored) { + mtime = 0L; // unknown — still hash-stable across runs + } + out.put(key, new long[]{size, mtime}); + } catch (IOException e) { + // Skip resources we can't stat — they'll be picked up by the + // slow path which reads them anyway. + log.debug("Failed to stat resource {}: {}", res.getDescription(), e.getMessage()); + } + } + return out; + } + + /** Element-wise compare of two manifests (TreeMap ordering not assumed). */ + private static boolean manifestEquals(Map a, Map b) { + if (a == null || b == null) return a == b; + if (a.size() != b.size()) return false; + for (Map.Entry e : a.entrySet()) { + long[] other = b.get(e.getKey()); + if (other == null) return false; + long[] mine = e.getValue(); + if (mine.length != other.length) return false; + for (int i = 0; i < mine.length; i++) { + if (mine[i] != other[i]) return false; + } + } + return true; + } + + private long countBuiltinRows() { + try { + Long n = skillMapper.selectCount( + new LambdaQueryWrapper() + .eq(SkillEntity::getSkillType, SKILL_TYPE_BUILTIN) + .eq(SkillEntity::getDeleted, 0)); + return n != null ? n : 0L; + } catch (Exception e) { + log.debug("Failed to count builtin skills: {}", e.getMessage()); + return -1L; // mismatch sentinel — forces full re-scan + } + } + + private SeedSnapshot loadSnapshot() { + Path path = snapshotPath(); + if (!Files.isRegularFile(path)) return null; + try { + String json = Files.readString(path, StandardCharsets.UTF_8); + Map root = objectMapper.readValue( + json, new TypeReference<>() {}); + int version = toLong(root.get("version"), 0L).intValue(); + long rowCount = toLong(root.get("rowCount"), 0L); + // Manifest entries arrive as List — each element may be a + // JSON number (Integer/Long) OR a quoted string. The mateclaw + // global ObjectMapper serializes long values as strings to avoid + // JS precision loss (mtime is 13-digit ms-since-epoch). Coerce + // both shapes back to long here. + Map> rawManifest = objectMapper.convertValue( + root.getOrDefault("manifest", Map.of()), + new TypeReference<>() {}); + Map manifest = new TreeMap<>(); + for (Map.Entry> e : rawManifest.entrySet()) { + List arr = e.getValue(); + if (arr == null || arr.size() < 2) continue; + manifest.put(e.getKey(), new long[]{ + toLong(arr.get(0), 0L), + toLong(arr.get(1), 0L), + }); + } + return new SeedSnapshot(version, manifest, rowCount); + } catch (Exception e) { + log.debug("Could not read seed snapshot {}: {}", path, e.getMessage()); + return null; + } + } + + /** Coerce JSON-decoded value to long. Handles both numeric (Integer / + * Long / Double) and string-encoded-long forms — the latter is what + * mateclaw's ObjectMapper emits for long fields by default. */ + private static Long toLong(Object value, Long fallback) { + if (value == null) return fallback; + if (value instanceof Number n) return n.longValue(); + try { + return Long.parseLong(value.toString().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private void writeSnapshot(SeedSnapshot snapshot) { + Path path = snapshotPath(); + try { + Files.createDirectories(path.getParent()); + // Serialize the long[] entries as plain List for portable JSON. + Map> serializableManifest = new LinkedHashMap<>(); + for (Map.Entry e : snapshot.manifest().entrySet()) { + long[] v = e.getValue(); + serializableManifest.put(e.getKey(), List.of(v[0], v[1])); + } + Map root = new LinkedHashMap<>(); + root.put("version", snapshot.version()); + root.put("rowCount", snapshot.rowCount()); + root.put("manifest", serializableManifest); + Files.writeString(path, objectMapper.writeValueAsString(root), StandardCharsets.UTF_8); + } catch (Exception e) { + log.debug("Could not write seed snapshot {}: {}", path, e.getMessage()); + } + } + private String readContent(Resource resource) throws Exception { try (InputStream is = resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); 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 2386c59b..36b6531b 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 @@ -17,17 +17,22 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.SkillWorkspaceManager; import vip.mate.tool.ToolRegistry; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; /** @@ -82,6 +87,17 @@ public class SkillPackageResolver { private final java.util.concurrent.ConcurrentHashMap> registeredWrappers = new java.util.concurrent.ConcurrentHashMap<>(); + /** + * Content-hash → cached scan outcome, so a refresh on unchanged + * SKILL.md content reuses the previous result instead of running + * the security scanner again. Builtin skills already short-circuit + * up-front; this cache helps user-installed dynamic skills, where + * the same row gets re-resolved on every refresh. + */ + private final ConcurrentMap securityScanCache = new ConcurrentHashMap<>(); + + private record CachedScanOutcome(String contentHash, SkillValidationResult result) {} + @Autowired public SkillPackageResolver(SkillFrontmatterParser frontmatterParser, SkillManifestParser manifestParser, @@ -365,8 +381,36 @@ public class SkillPackageResolver { // ==================== 阶段 2:安全扫描 ==================== private void applySecurity(ResolvedSkill resolved) { + // Builtin skills come from classpath/jar — they're version-controlled + // upstream and the resolver flow already maps blocked findings to + // `trustedBuiltin` (warn but don't block). Running the full scanner + // on every refresh is pure overhead; with 30+ shipped skills this + // dominates the refresh budget. Mark them trusted up-front and + // skip — the persisted scan_result on mate_skill (written by the + // initial resolve) keeps the UI's audit trail intact. + if (resolved.isBuiltin()) { + resolved.setSecurityBlocked(false); + resolved.setSecuritySummary("Trusted builtin (runtime scan skipped)"); + resolved.setSecurityWarnings(List.of()); + return; + } try { - SkillValidationResult result = securityService.validate(resolved); + // Content-hash cache: refreshes on unchanged SKILL.md content + // skip the (potentially expensive) scanner. Hash drifts → fall + // through to a fresh scan and replace the cached entry. + String contentHash = sha256(resolved.getContent()); + CachedScanOutcome cached = resolved.getId() != null + ? securityScanCache.get(resolved.getId()) + : null; + SkillValidationResult result; + if (cached != null && cached.contentHash().equals(contentHash)) { + result = cached.result(); + } else { + result = securityService.validate(resolved); + if (resolved.getId() != null) { + securityScanCache.put(resolved.getId(), new CachedScanOutcome(contentHash, result)); + } + } boolean trustedBuiltin = resolved.isBuiltin() && result.isBlocked(); resolved.setSecurityBlocked(result.isBlocked() && !trustedBuiltin); resolved.setSecuritySeverity(result.getMaxSeverity() != null ? result.getMaxSeverity().name() : null); @@ -658,6 +702,10 @@ public class SkillPackageResolver { */ public void deregisterSkillWrappers(Long skillId) { if (skillId == null) return; + // Evict the security-scan cache entry too — a re-installed skill + // with the same id but different content needs a fresh scan, and + // a permanently-deleted skill should free the slot. + securityScanCache.remove(skillId); java.util.Set previous = registeredWrappers.remove(skillId); if (previous == null || previous.isEmpty()) return; for (String name : previous) { @@ -670,6 +718,19 @@ public class SkillPackageResolver { log.info("Deregistered {} wrapper tool(s) for skill id={}", previous.size(), skillId); } + /** Hex-encoded SHA-256 of the input string (UTF-8). Empty/null → "". */ + private static String sha256(String input) { + if (input == null || input.isEmpty()) return ""; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + // SHA-256 is mandatory in the JRE — should never throw. + // Fall back to identity so the cache is just always-miss. + return input; + } + } + /** * RFC-090 Phase 7b — register / refresh / deregister wrapper tools * for a single ACP skill. Mirrors {@link #applyKnowledgeWrappers}. 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 7178deda..a03af2dc 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 @@ -17,11 +17,17 @@ import vip.mate.skill.service.SkillService; import vip.mate.skill.workspace.SkillWorkspaceEvent; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import java.time.Duration; import java.util.List; import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; /** @@ -75,6 +81,26 @@ public class SkillRuntimeService { private static final String CACHE_KEY = "active_skills"; + /** + * Debounce window for {@link #onWorkspaceEvent}. Startup typically + * fires one event per bundled skill (30+ in a row), and there's + * nothing useful to do until the whole batch settles. 500 ms is + * long enough to swallow the burst without making admin re-syncs + * feel laggy. + */ + private static final long REFRESH_DEBOUNCE_MS = 500; + + private final ScheduledExecutorService refreshScheduler = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "skill-refresh-debouncer"); + t.setDaemon(true); + return t; + }); + + /** Most recent pending refresh future — atomically swapped on each + * event so the previous one can be cancelled. */ + private final AtomicReference> pendingRefresh = new AtomicReference<>(); + @PostConstruct public void init() { log.info("SkillRuntimeService initialized"); @@ -90,8 +116,42 @@ public class SkillRuntimeService { @EventListener(SkillWorkspaceEvent.class) public void onWorkspaceEvent(SkillWorkspaceEvent event) { - log.info("Workspace event: {} {} at {}", event.type(), event.skillName(), event.workspacePath()); - refreshActiveSkills(); + // Coalesce bursts of workspace events (every bundled-skill sync at + // startup fires one) into a single refresh. Without debounce, a + // 30-skill startup triggered 30 sequential refreshActiveSkills() + // calls — each running the whole resolve+scan loop. With 500 ms + // debounce it collapses to one. + log.debug("Workspace event: {} {} (refresh scheduled in {}ms)", + event.type(), event.skillName(), REFRESH_DEBOUNCE_MS); + ScheduledFuture previous = pendingRefresh.get(); + if (previous != null && !previous.isDone()) { + previous.cancel(false); + } + ScheduledFuture task = refreshScheduler.schedule(() -> { + try { + refreshActiveSkills(); + } catch (Exception e) { + log.warn("Debounced refresh failed: {}", e.getMessage()); + } + }, REFRESH_DEBOUNCE_MS, TimeUnit.MILLISECONDS); + pendingRefresh.set(task); + } + + @PreDestroy + public void shutdown() { + // Drain any pending refresh so JVM shutdown doesn't hang on the + // daemon thread, even though it's marked daemon and would die anyway. + ScheduledFuture pending = pendingRefresh.getAndSet(null); + if (pending != null) pending.cancel(true); + refreshScheduler.shutdown(); + try { + if (!refreshScheduler.awaitTermination(2, TimeUnit.SECONDS)) { + refreshScheduler.shutdownNow(); + } + } catch (InterruptedException e) { + refreshScheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } } /** @@ -261,63 +321,44 @@ public class SkillRuntimeService { } else { activeSkills = getActiveSkills(); } + // Platform filter — drop skills whose `platforms:` frontmatter + // names a different OS than the runtime host. apple-notes / + // findmy etc. are macOS-only; surfacing them on Linux just + // burns prompt tokens for skills the user can never run. + // Empty / missing `platforms:` means "all platforms" (the default). + String currentOs = currentOsCanonical(); + activeSkills = activeSkills.stream() + .filter(s -> matchesCurrentPlatform(s, currentOs)) + .collect(java.util.stream.Collectors.toList()); if (activeSkills.isEmpty()) { return ""; } - // Issue #46 + #49 prompt rewrite. Stop-gap until RFC-090 lands the - // proper `type` enum and inlines `type: prompt` skill bodies into the - // system prompt directly. Today every skill is announced via this - // catalog and the LLM has to pull SKILL.md on demand. Two failure - // modes have been observed: - // #46 — LLM treated the skill name as a tool ("tool_use{name= - // RedisOps}"), so we lead with an explicit "NOT callable" - // warning and surface the actual tool names readSkillFile / - // runSkillScript. - // #49 — On a docs-only skill (SKILL.md, no scripts/ dir), the LLM - // tried to invoke a non-existent scripts/ file and never - // followed SKILL.md's text guidance. The previous wording - // "usually that means calling runSkillScript(...)" actively - // pushed it that way. Fix: tell the model the two shapes - // exist, mark each row's shape, and forbid invoking scripts - // on docs-only skills. + // Compact preamble — was ~1 KB of warnings before. The two failure + // modes that drove the older wording (#46: LLM treats skill name + // as a tool; #49: LLM invokes runSkillScript on a docs-only skill) + // are now addressed in two cheaper spots: + // - readSkillFile/runSkillScript tools have explicit "Tool not + // found" errors that nudge the model to retry the right way. + // - SKILL.md itself, once loaded via readSkillFile, tells the + // model whether to invoke a script or just follow the prose. + // 49 skills × ~20 chars/row saved by dropping the Shape column + + // ~600 chars saved by trimming the preamble = ~1.5 KB / ~400 + // tokens lighter on every chat request. StringBuilder sb = new StringBuilder(); - sb.append("\n\n## Available Skills\n\n"); - sb.append("⚠️ **Skills are documentation packages, NOT directly callable tools.**\n"); - sb.append("Calling a skill name as a tool (e.g. tool_use{name=\"RedisOps\"}) will fail with \"Tool not found\". "); - sb.append("To use a skill:\n\n"); - sb.append("1. ALWAYS first read its SKILL.md to learn how it works:\n"); - sb.append(" `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")`\n"); - sb.append("2. Then follow what SKILL.md says. Skills come in two shapes — check the **Shape** column below:\n"); - sb.append(" - **docs only** — no `scripts/` directory exists. SKILL.md is the entire instruction set; follow its text guidance directly. Do NOT call `runSkillScript` on these — the script does not exist and the call will fail.\n"); - sb.append(" - **scripts + docs** — `scripts/` is present. SKILL.md will name the script to run; invoke it with `runSkillScript(skillName=\"\", scriptPath=\"scripts/\")`.\n"); - sb.append(" Either shape may also expose supplementary docs via `readSkillFile(skillName=\"\", filePath=\"references/\")`.\n\n"); - - // Concrete example anchored to the first enabled skill so the LLM - // sees a real name it just read in the listing below. - String exampleName = activeSkills.get(0).getName(); - sb.append("Concrete example — to use the `").append(exampleName).append("` skill, START with:\n"); - sb.append(" `readSkillFile(skillName=\"").append(exampleName).append("\", filePath=\"SKILL.md\")`\n\n"); - - sb.append("### Enabled skills\n"); - sb.append("Pass these names as the `skillName=` argument to `readSkillFile` / `runSkillScript`. "); - sb.append("Do **not** call them as tools.\n\n"); - sb.append("| Skill name | Shape | Description |\n"); - sb.append("|------------|-------|-------------|\n"); + sb.append("\n\n## Skills\n"); + sb.append("Before answering, scan the skills below. If a skill matches your task, "); + sb.append("load it via `readSkillFile(skillName=, filePath=\"SKILL.md\")` and follow its instructions. "); + 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("| Skill | Description |\n"); + sb.append("|-------|-------------|\n"); for (ResolvedSkill skill : activeSkills) { - // `scripts` is populated by SkillDirectoryScanner — empty map both - // for "scripts/ directory absent" and "directory present but empty". - // Either way, runSkillScript has nothing to call, so we report the - // skill as docs-only. (Database-fallback skills also land here - // because SkillPackageResolver.resolveFromDatabase sets it to - // Map.of().) RFC-090's `type` field will replace this heuristic. - boolean hasScripts = skill.getScripts() != null && !skill.getScripts().isEmpty(); - String shape = hasScripts ? "scripts + docs" : "docs only"; sb.append("| `").append(skill.getName()).append("`"); if (skill.getIcon() != null && !skill.getIcon().isBlank()) { sb.append(" ").append(skill.getIcon()); } - sb.append(" | ").append(shape).append(" | "); + sb.append(" | "); if (skill.getDescription() != null && !skill.getDescription().isBlank()) { String desc = skill.getDescription(); if (desc.length() > 200) { @@ -367,4 +408,36 @@ public class SkillRuntimeService { sb.append(lessons); } } + + /** + * Map {@code System.getProperty("os.name")} to one of the canonical + * tokens used in SKILL.md {@code platforms:} ({@code macos / linux / + * windows}). Anything unrecognised → {@code "other"} which never + * matches a declared platform list, so the skill stays visible only + * if its platforms list is empty (the "all platforms" default). + */ + static String currentOsCanonical() { + String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT); + if (os.contains("mac") || os.contains("darwin")) return "macos"; + if (os.contains("nux") || os.contains("nix")) return "linux"; + if (os.contains("win")) return "windows"; + return "other"; + } + + /** + * True when the skill is compatible with {@code currentOs}. A skill + * with empty / null {@code platforms:} matches every OS (legacy + * default). Otherwise the canonical OS token must appear in the list. + */ + static boolean matchesCurrentPlatform(ResolvedSkill skill, String currentOs) { + SkillManifest manifest = skill.getManifest(); + if (manifest == null) return true; + List platforms = manifest.getPlatforms(); + if (platforms == null || platforms.isEmpty()) return true; + for (String p : platforms) { + if (p == null) continue; + if (currentOs.equalsIgnoreCase(p.trim())) return true; + } + return false; + } }