From 5789a28e19137612a4941ea31b0b820aa940e712 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 12 May 2026 17:20:09 +0800 Subject: [PATCH] feat(skill): boost newly installed skills + teach LLM to widen the catalog search --- .../skill/runtime/SkillPackageResolver.java | 2 + .../skill/runtime/SkillRuntimeService.java | 38 ++++++++++++++++- .../skill/runtime/model/ResolvedSkill.java | 11 +++++ .../vip/mate/tool/builtin/SkillFileTool.java | 41 +++++++++++++++++-- 4 files changed, 86 insertions(+), 6 deletions(-) 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 36b6531b..d6ee4115 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 @@ -335,6 +335,7 @@ public class SkillPackageResolver { .enabled(Boolean.TRUE.equals(entity.getEnabled())) .icon(entity.getIcon()) .builtin(Boolean.TRUE.equals(entity.getBuiltin())) + .createTime(entity.getCreateTime()) .build(); } @@ -375,6 +376,7 @@ public class SkillPackageResolver { .enabled(Boolean.TRUE.equals(entity.getEnabled())) .icon(entity.getIcon()) .builtin(Boolean.TRUE.equals(entity.getBuiltin())) + .createTime(entity.getCreateTime()) .build(); } 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 d332cce5..008d4553 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 @@ -395,10 +395,17 @@ public class SkillRuntimeService { int descLimit = promptDescriptionLimit(maxInputTokens); Set recentNames = usageService.recentLoadedSkillNames(agentId, 8); Set frequentNames = usageService.frequentlyLoadedSkillNames(8); + // Boost freshly installed skills for a short window so a skill the + // user *just* added is visible in the compact catalog before it has + // any usage history. Without this, qwen-turbo-style 8-entry budgets + // 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) -> recentNames.contains(s.getName()) ? 0 : 1) + .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(); @@ -416,7 +423,12 @@ public class SkillRuntimeService { 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. "); - sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog. "); + 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 — "); + 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("| Skill | Status | Description |\n"); @@ -458,6 +470,28 @@ public class SkillRuntimeService { return tools == null || tools.isEmpty() || effectiveToolNames.containsAll(tools); } + /** + * Treat skills installed within this window as "new" for the prompt + * catalog ranker. Long enough that a user who installs on Friday and + * comes back Monday still sees the boost; short enough that the + * catalog reverts to usage-based ordering before the boost slot + * crowds out genuinely useful skills. + */ + public static final java.time.Duration NEW_SKILL_BOOST_WINDOW = java.time.Duration.ofDays(7); + + /** + * Returns true if the skill's row was created after {@code cutoff}. + * Builtins and virtual MCP/ACP skills typically have no createTime; + * they are not boosted (the user didn't just install them). Public so + * the user-facing {@code listAvailableSkills} catalog can apply the + * same boost as the prompt enhancement. + */ + public static boolean isRecentlyInstalled(ResolvedSkill skill, java.time.LocalDateTime cutoff) { + if (skill == null || skill.getCreateTime() == null) return false; + if (skill.isBuiltin()) return false; + return skill.getCreateTime().isAfter(cutoff); + } + private static int promptCatalogEntryLimit(Integer maxInputTokens) { int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192; if (max <= 8192) return 8; 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 262dd203..6df78e20 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 @@ -6,6 +6,7 @@ import lombok.Data; import vip.mate.skill.manifest.SkillManifest; import java.nio.file.Path; +import java.time.LocalDateTime; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -72,6 +73,16 @@ public class ResolvedSkill { @Builder.Default private boolean builtin = false; + /** + * Skill row create timestamp, copied from {@code mate_skill.create_time}. + * Used by the prompt-catalog ranker to surface freshly installed skills + * before they accumulate any usage stats — without this, a brand-new + * skill stays invisible behind the recent/frequent/alphabetical sort + * and the LLM ends up replying "no such skill" right after the user + * installed it. Null for virtual MCP/ACP skills that don't own a row. + */ + private LocalDateTime createTime; + // ==================== 安全扫描状态 ==================== /** 是否被安全扫描阻断 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index a6167a8c..c2f14b36 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -265,7 +265,7 @@ public class SkillFileTool { } @Tool(description = """ - List all currently available Skills (documentation packages). + List currently available Skills (documentation packages). IMPORTANT: Skills are NOT directly callable as tools. Each name returned here is a `skillName` argument, not a tool name. To use @@ -273,6 +273,17 @@ public class SkillFileTool { first to read its instructions, then follow what SKILL.md tells you. Calling a skill name as a tool will fail with "Tool not found". + Search strategy when looking for a specific skill: + - The default page is 20 of N — if "Showing: 20 of " appears + and you don't see what you're after, retry with `keyword=` + (matched against name + description, case-insensitive) or raise `limit` + up to 50. + - If the user mentions an exact skill name (e.g. "tencent-meeting-mcp"), + skip this tool and go straight to + `readSkillFile(skillName="", filePath="SKILL.md")` — + that bypasses the catalog truncation entirely and either returns + the skill's instructions or a clear "skill not found" error. + Note: this returns Skills (vendor-installable docs), not Agents. For Agents, use `listAvailableAgents`. @@ -280,7 +291,7 @@ public class SkillFileTool { """) public String listAvailableSkills( @JsonProperty(required = false) - @JsonPropertyDescription("Optional keyword matched against skill name or description") + @JsonPropertyDescription("Optional keyword matched against skill name or description (case-insensitive). Use this when a specific skill name was mentioned but didn't appear in the default page.") String keyword, @JsonProperty(required = false) @@ -299,6 +310,16 @@ public class SkillFileTool { int safeLimit = limit == null || limit <= 0 ? 20 : Math.min(limit, 50); String kw = keyword == null ? "" : keyword.trim().toLowerCase(); + // Push freshly installed skills to the top of the truncated page so + // a user who just installed something can still find it without + // remembering to pass keyword=. Same window the prompt catalog uses. + java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now() + .minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW); + // sortResolved gives the RECOMMENDED ordering; the secondary sort + // below uses the JDK's stable sort to lift recently-installed skills + // to the top while preserving RECOMMENDED order among same-recency + // entries — no need to thread the (package-private) recommended + // comparator back through here. List activeSkills = SkillCatalogSorter.sortResolved( runtimeService.getActiveSkills().stream() .filter(s -> SkillCatalogSorter.sourceMatches(s, source)) @@ -307,7 +328,10 @@ public class SkillFileTool { || containsIgnoreCase(s.getName(), kw) || containsIgnoreCase(s.getDescription(), kw)) .toList(), - SkillCatalogSort.RECOMMENDED); + SkillCatalogSort.RECOMMENDED).stream() + .sorted(java.util.Comparator.comparingInt((ResolvedSkill s) -> + SkillRuntimeService.isRecentlyInstalled(s, recencyCutoff) ? 0 : 1)) + .toList(); if (activeSkills.isEmpty()) { return "No skills are currently available."; @@ -338,8 +362,17 @@ public class SkillFileTool { } sb.append(" |\n"); } - sb.append("\nShowing: ").append(Math.min(safeLimit, activeSkills.size())) + int shown = Math.min(safeLimit, activeSkills.size()); + sb.append("\nShowing: ").append(shown) .append(" of ").append(activeSkills.size()).append(" skill(s)."); + if (shown < activeSkills.size()) { + // Surface the truncation hint so the LLM knows how to widen the + // search instead of concluding the missing skill doesn't exist. + sb.append(" Result truncated — retry with `keyword=` ") + .append("to search the full catalog, or `limit=50` to see more rows. ") + .append("If the user gave an exact skill name, prefer ") + .append("`readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly."); + } return sb.toString(); }