package vip.mate.skill.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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; import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSorter; import vip.mate.skill.secret.SkillSecretService; import vip.mate.skill.workspace.SkillWorkspaceManager; import vip.mate.skill.workspace.SkillWorkspaceProperties; import java.nio.file.Files; import java.nio.file.Path; import java.util.Set; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 技能业务服务 *
* 负责技能的 CRUD 管理、启用/禁用控制,以及与 Agent 运行时的集成。 * Skill 在 MateClaw 中的定位是"可扩展的能力模块",分为三种类型: *
RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters * are all optional; empty or {@code null} means "no filter". Keyword * searches name / description / tags with LIKE. * *
{@code scanStatus} (RFC-042 §2.3.5) filters on {@code * 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 The resolver itself persists the outcome — this method just kicks
* it and returns the reloaded row.
*/
public SkillEntity rescanSecurity(Long id) {
SkillEntity skill = getSkill(id); // throws MateClawException if missing
if (runtimeService == null) {
throw new MateClawException("err.skill.runtime_unavailable",
"Skill runtime not initialized yet; retry in a moment");
}
runtimeService.rescanSingle(skill);
return skillMapper.selectById(id);
}
/**
* Aggregate skill counts per {@code skill_type}, plus an {@code all}
* rollup. Feeds the SkillMarket tab badges without pulling every row.
* Scoped to {@code workspaceId}: builtin skills count for every
* workspace, all other skills only for their owning workspace.
*/
public Map
* RFC-023:追加 security_scan_status 过滤——FAILED 的 skill 不加载。
* NULL(旧数据/手动创建)和 PASSED(扫描通过)都允许。
*/
public List
* 内置技能允许修改的字段集合:
* The UI sends a partial body that only contains the fields the
* user edited (Identity edit → {@code nameZh/nameEn/description/tags/icon};
* Body edit → {@code skillContent}, plus optional {@code sourceCode}).
* Every other field on the deserialized entity is {@code null}.
*
* {@link SkillEntity} declares several
* {@code @TableField(updateStrategy = FieldStrategy.ALWAYS)} columns
* — {@code name_zh}, {@code name_en}, {@code config_json},
* {@code source_code}, {@code skill_content}, {@code manifest_json},
* {@code security_scan_result}. Calling
* {@code skillMapper.updateById(partial)} would tell MyBatis Plus to
* write {@code NULL} into every ALWAYS column missing from the
* partial, wiping perfectly valid content on every save. The earlier
* #45 fix only protected the resolver's scan write-back; this path
* was still exposed (and surfaced as issue #93 when a partial PUT
* also took the workspace-sync branch with a {@code null} name and
* NPE'd inside {@code sanitizeName}).
*
* Fix: merge non-null fields from the partial onto a copy of the
* existing row, then persist the merged entity. Same shape as the
* builtin branch above, just with a wider whitelist for dynamic
* skills.
*/
public SkillEntity updateSkill(SkillEntity skill) {
SkillEntity existing = getSkill(skill.getId());
if (Boolean.TRUE.equals(existing.getBuiltin())) {
// Functional fields
existing.setEnabled(skill.getEnabled() != null ? skill.getEnabled() : existing.getEnabled());
if (skill.getConfigJson() != null) existing.setConfigJson(skill.getConfigJson());
existing.setDescription(skill.getDescription() != null ? skill.getDescription() : existing.getDescription());
if (skill.getSkillContent() != null) {
existing.setSkillContent(skill.getSkillContent());
}
// Display overrides — pure frontend-facing, no runtime impact.
// Treat blank-string as an explicit "clear" (so the picker's
// "no icon" path reverts the row icon back to the manifest
// projection on the next resolve).
if (skill.getIcon() != null) existing.setIcon(skill.getIcon());
if (skill.getNameZh() != null) existing.setNameZh(skill.getNameZh());
if (skill.getNameEn() != null) existing.setNameEn(skill.getNameEn());
if (skill.getTags() != null) existing.setTags(skill.getTags());
skillMapper.updateById(existing);
log.info("Updated builtin skill (display overrides allowed): {}", existing.getName());
// builtin skill 也同步 workspace SKILL.md
syncSkillContentToWorkspace(existing);
// 刷新 runtime cache
if (runtimeService != null) {
runtimeService.refreshActiveSkills();
}
return existing;
}
// 非内置技能:merge non-null fields from the partial onto the
// existing row. name / skillType / builtin stay locked because
// they're identity fields whose change would orphan bindings
// and break the resolver.
if (skill.getDescription() != null) existing.setDescription(skill.getDescription());
if (skill.getIcon() != null) existing.setIcon(skill.getIcon());
if (skill.getVersion() != null && !skill.getVersion().isBlank()) existing.setVersion(skill.getVersion());
if (skill.getAuthor() != null) existing.setAuthor(skill.getAuthor());
if (skill.getEnabled() != null) existing.setEnabled(skill.getEnabled());
if (skill.getTags() != null) existing.setTags(skill.getTags());
if (skill.getNameZh() != null) existing.setNameZh(skill.getNameZh());
if (skill.getNameEn() != null) existing.setNameEn(skill.getNameEn());
if (skill.getConfigJson() != null) existing.setConfigJson(skill.getConfigJson());
if (skill.getSourceCode() != null) existing.setSourceCode(skill.getSourceCode());
if (skill.getSkillContent() != null) existing.setSkillContent(skill.getSkillContent());
if (skill.getManifestJson() != null) existing.setManifestJson(skill.getManifestJson());
existing.setBuiltin(false);
skillMapper.updateById(existing);
log.info("Updated skill: {}", existing.getName());
// A manual edit counts as activity — keep the skill anchored to now.
lifecycleService.bumpActivity(existing.getId());
// 若 skillContent 变更且约定工作区存在,同步 SKILL.md
syncSkillContentToWorkspace(existing);
// 刷新 runtime cache
if (runtimeService != null) {
runtimeService.refreshActiveSkills();
}
return existing;
}
/**
* RFC-090 §14.5 — uninstall path: logical delete (row stays in DB
* with {@code deleted=1}) + archive workspace to
* {@code .archived/}.
*
* Recoverable: the user can re-install a skill of the same name
* later, since the archived workspace and soft-deleted row stay
* out of every standard query but on disk.
*
* Builtin skills are protected: uninstalling a builtin would
* leave the seed re-creating it on next boot, so we surface a
* clear error instead of doing partial work.
*/
public void uninstallSkill(Long id) {
SkillEntity skill = getSkill(id);
if (Boolean.TRUE.equals(skill.getBuiltin())) {
throw new MateClawException("err.skill.builtin_readonly",
"内置技能不可卸载: " + skill.getName());
}
skillMapper.deleteById(id); // logical delete (deleted=1)
log.info("Uninstalled skill (logical delete + archive): {}", skill.getName());
// Notify listeners (e.g. agent-binding cleanup) so dependent rows
// referencing this skill_id don't outlive the row itself.
eventPublisher.publishEvent(new SkillRemovedEvent(id, skill.getName()));
if ("archive".equals(workspaceProperties.getDeletePolicy())) {
workspaceManager.archiveWorkspace(skill.getName());
}
// RFC-090 review #3 — refresh won't deregister wrappers for a
// soft-deleted row (it only resolves rows still in
// listEnabledSkills), so do it explicitly here.
if (runtimeService != null) {
runtimeService.deregisterSkillWrappers(id);
runtimeService.refreshActiveSkills();
}
}
/**
* RFC-090 §14.5 — hard-delete path: physical SQL delete + workspace
* purge. Admin only. Not recoverable.
*
* Use this when you need to free the slug for an unrelated skill
* or scrub a row that's been corrupted. UI buttons should call
* {@link #uninstallSkill} instead unless the user explicitly chose
* "permanently delete".
*/
public void hardDeleteSkill(Long id) {
SkillEntity skill = getSkill(id);
if (Boolean.TRUE.equals(skill.getBuiltin())) {
throw new MateClawException("err.skill.builtin_readonly",
"内置技能不可硬删除: " + skill.getName());
}
skillMapper.hardDeleteById(id); // bypass the logical-delete flag
int filesDropped = skillFileMapper.deleteBySkillId(id);
if (filesDropped > 0) {
log.info("Hard-deleted {} bundle file row(s) for skill {}", filesDropped, skill.getName());
}
log.info("Hard-deleted skill (physical delete + purge): {}", skill.getName());
// Same notification as the uninstall path — agent-binding cleanup
// applies regardless of which delete flavor the admin chose.
eventPublisher.publishEvent(new SkillRemovedEvent(id, skill.getName()));
// RFC-091 settings bridge — purge any per-skill secrets so a
// future skill reusing this id doesn't inherit stale credentials.
try {
int purged = skillSecretService.purgeForSkill(id);
if (purged > 0) {
log.info("Purged {} secret(s) for hard-deleted skill {}", purged, skill.getName());
}
} catch (Exception e) {
log.warn("Failed to purge secrets for skill {}: {}", skill.getName(), e.getMessage());
}
workspaceManager.purgeWorkspace(skill.getName());
// RFC-090 review #3 — same explicit deregister as uninstall.
if (runtimeService != null) {
runtimeService.deregisterSkillWrappers(id);
runtimeService.refreshActiveSkills();
}
}
/**
* Legacy alias maintained for callers that still use {@code
* deleteSkill}. Routes to the user-friendly uninstall semantics
* (logical delete + archive). New code should pick {@link
* #uninstallSkill} or {@link #hardDeleteSkill} explicitly.
*
* @deprecated Use {@link #uninstallSkill} for user-facing UI delete
* (recoverable) or {@link #hardDeleteSkill} for admin
* "permanent" delete.
*/
@Deprecated
public void deleteSkill(Long id) {
uninstallSkill(id);
}
/**
* 启用/禁用技能
*/
public SkillEntity toggleSkill(Long id, boolean enabled) {
SkillEntity skill = getSkill(id);
skill.setEnabled(enabled);
skillMapper.updateById(skill);
log.info("Skill {} {}", skill.getName(), enabled ? "enabled" : "disabled");
// Re-enabling a skill is an explicit "I use this again" signal.
if (enabled) {
lifecycleService.bumpActivity(id);
}
// RFC-090 review #3 — when disabling, explicitly tear down any
// registered wrapper tools (knowledge / acp). Without this the
// wrappers stay advertised because the availability supplier
// closes over the snapshot ResolvedSkill captured at registration.
if (!enabled && runtimeService != null) {
runtimeService.deregisterSkillWrappers(id);
}
// 刷新 runtime cache
if (runtimeService != null) {
runtimeService.refreshActiveSkills();
}
return skill;
}
// ==================== Agent 运行时集成 ====================
/**
* Token 预算上限(字符数近似值,1 token ≈ 2 个中文字 / 4 个英文字符)
* 默认 6000 字符 ≈ ~2000 tokens,为对话上下文预留足够空间
*/
private static final int DEFAULT_SKILL_PROMPT_BUDGET = 6000;
/**
* 构建技能 Prompt 增强片段(带 Token 预算控制)
*
* 优化策略(对比旧版全量注入):
*
* 优先级:skillContent(SKILL.md 协议) > description
* 不再使用 sourceCode(可能包含大量代码,容易爆 token)
*/
private String resolveSkillContent(SkillEntity skill) {
// 优先使用 SKILL.md 内容(执行协议)
if (skill.getSkillContent() != null && !skill.getSkillContent().isBlank()) {
return skill.getSkillContent();
}
// 回退到 description(兼容旧数据)
return skill.getDescription();
}
/**
* 获取已启用技能的摘要信息(用于 Agent 状态展示)
*/
public Map
*
* 仍不允许:name / version / author / skillType / builtin —— 这些是身份字段,
* 改动会破坏绑定与解析。
*
*
注:{@code icon} 是 manifest 投影字段,会被 SkillPackageResolver
* 根据 SKILL.md frontmatter 同步。配套的解析器修改(仅在 row icon 为空时
* 才投影)确保用户覆盖能持久化。
*
*
*
* @return systemPrompt 增强片段,可直接拼接到 Agent 的 systemPrompt 末尾
*/
public String buildSkillPromptEnhancement() {
return buildSkillPromptEnhancement(DEFAULT_SKILL_PROMPT_BUDGET);
}
/**
* 构建技能 Prompt 增强片段(可指定 Token 预算)
*
* @param charBudget 最大字符预算(超出时自动截断详情)
*/
public String buildSkillPromptEnhancement(int charBudget) {
List