From e22173a4761740910822d08600fa56e927d56059 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 6 Aug 2026 01:51:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(skill):=20close=20the=20self-evolution=20l?= =?UTF-8?q?oop=20=E2=80=94=20auto-bind,=20routine=20mining,=20curation=20p?= =?UTF-8?q?rovenance=20and=20restore=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binding/AgentSkillAutoBindListener.java | 92 ++++ .../vip/mate/common/text/SecretRedactor.java | 70 ++++ .../java/vip/mate/common/text/Shingles.java | 85 ++++ .../service/StructuredMemoryService.java | 31 +- .../skill/controller/SkillController.java | 113 +++++ .../mate/skill/event/SkillAuthoredEvent.java | 35 ++ .../lifecycle/SkillConsolidationService.java | 4 +- .../mate/skill/lifecycle/SkillCuratorJob.java | 23 +- .../lifecycle/SkillLifecycleProperties.java | 10 + .../skill/lifecycle/SkillSnapshotService.java | 226 ++++++++++ .../lifecycle/model/SkillSnapshotEntity.java | 43 ++ .../repository/SkillSnapshotMapper.java | 14 + .../vip/mate/skill/model/SkillEntity.java | 16 + .../vip/mate/skill/model/SkillOrigin.java | 57 +++ .../reflection/SkillReflectionService.java | 81 +++- .../SkillRoutineAutoConfiguration.java | 14 + .../mate/skill/routine/SkillRoutineJob.java | 46 ++ .../mate/skill/routine/SkillRoutineMiner.java | 395 ++++++++++++++++++ .../skill/routine/SkillRoutinePromoter.java | 292 +++++++++++++ .../skill/routine/SkillRoutineProperties.java | 64 +++ .../skill/routine/SkillRoutineService.java | 142 +++++++ .../model/SkillRoutineCandidateEntity.java | 91 ++++ .../SkillRoutineCandidateMapper.java | 14 + .../mate/tool/builtin/SkillManageTool.java | 46 +- .../src/main/resources/application.yml | 71 ++++ .../h2/V176__skill_routine_candidate.sql | 43 ++ .../h2/V177__skill_origin_and_snapshot.sql | 39 ++ .../V176__skill_routine_candidate.sql | 44 ++ .../V177__skill_origin_and_snapshot.sql | 39 ++ .../mysql/V176__skill_routine_candidate.sql | 41 ++ .../mysql/V177__skill_origin_and_snapshot.sql | 44 ++ .../prompts/skill/reflect-system.txt | 53 ++- .../resources/prompts/skill/reflect-user.txt | 5 +- .../prompts/skill/routine-system.txt | 34 ++ .../resources/prompts/skill/routine-user.txt | 14 + .../AgentSkillAutoBindListenerTest.java | 106 +++++ .../mate/common/text/SecretRedactorTest.java | 73 ++++ .../vip/mate/common/text/ShinglesTest.java | 76 ++++ .../SkillControllerBundleFilesTest.java | 3 + .../SkillControllerLifecycleTest.java | 5 +- .../SkillControllerListEnabledTest.java | 3 + .../SkillControllerVirtualGuardTest.java | 10 +- .../SkillConsolidationServiceTest.java | 13 +- .../skill/lifecycle/SkillCuratorJobTest.java | 5 +- .../lifecycle/SkillSnapshotServiceTest.java | 211 ++++++++++ .../vip/mate/skill/model/SkillOriginTest.java | 47 +++ .../SkillReflectionServiceTest.java | 49 ++- .../skill/routine/SkillRoutineMinerTest.java | 134 ++++++ .../builtin/SkillManageToolWriteFileTest.java | 5 +- mateclaw-ui/src/api/index.ts | 26 ++ mateclaw-ui/src/i18n/locales/en-US.ts | 33 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 33 ++ .../src/views/Settings/SkillCurator/index.vue | 305 +++++++++++++- 53 files changed, 3490 insertions(+), 78 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/AgentSkillAutoBindListener.java create mode 100644 mateclaw-server/src/main/java/vip/mate/common/text/SecretRedactor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/common/text/Shingles.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/event/SkillAuthoredEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillSnapshotService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/lifecycle/model/SkillSnapshotEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/lifecycle/repository/SkillSnapshotMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/model/SkillOrigin.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineAutoConfiguration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineJob.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineMiner.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutinePromoter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/model/SkillRoutineCandidateEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/routine/repository/SkillRoutineCandidateMapper.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V176__skill_routine_candidate.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V177__skill_origin_and_snapshot.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V176__skill_routine_candidate.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V177__skill_origin_and_snapshot.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V176__skill_routine_candidate.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V177__skill_origin_and_snapshot.sql create mode 100644 mateclaw-server/src/main/resources/prompts/skill/routine-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/skill/routine-user.txt create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/binding/AgentSkillAutoBindListenerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/common/text/SecretRedactorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/common/text/ShinglesTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillSnapshotServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/model/SkillOriginTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/routine/SkillRoutineMinerTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/AgentSkillAutoBindListener.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/AgentSkillAutoBindListener.java new file mode 100644 index 00000000..9d1cd612 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/AgentSkillAutoBindListener.java @@ -0,0 +1,92 @@ +package vip.mate.agent.binding; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.skill.event.SkillAuthoredEvent; + +import java.util.Set; + +/** + * Makes a self-authored skill reachable from the catalog of the agent that + * authored it. + * + *

Why this exists

+ * An agent's visible skill catalog is filtered by + * {@link AgentBindingService#getBoundSkillIds(Long)}. That method has a + * three-state contract: + * + * + * + * Without this listener, an agent in the third state can author a skill, + * persist it, and then never see it again — the catalog renderer filters the + * new row straight out. Self-improvement writes into a hole: the skill exists + * in the registry but the agent that learned it cannot reach it on the next + * turn. + * + *

Binding policy

+ * Bind only when the agent is already in explicit-allowlist mode + * (non-null, non-empty). The other two states are deliberately left alone: + * + * + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentSkillAutoBindListener { + + private final AgentBindingService agentBindingService; + + @EventListener + public void onSkillAuthored(SkillAuthoredEvent event) { + if (event == null || event.agentId() == null || event.skillId() == null) { + return; + } + Set bound; + try { + bound = agentBindingService.getBoundSkillIds(event.agentId()); + } catch (Exception e) { + log.warn("[SkillAutoBind] Could not resolve bindings for agent={}: {}", + event.agentId(), e.getMessage()); + return; + } + // null = inherits every enabled skill; empty = explicitly scoped to + // none. Neither state should be rewritten by a background author. + if (bound == null || bound.isEmpty()) { + return; + } + if (bound.contains(event.skillId())) { + return; + } + try { + agentBindingService.bindSkill(event.agentId(), event.skillId()); + log.info("[SkillAutoBind] Bound self-authored skill '{}' (id={}) to agent={}", + event.skillName(), event.skillId(), event.agentId()); + } catch (Exception e) { + // A cross-workspace skill, a deleted agent, or a concurrent unbind + // all land here. The skill itself is already persisted and remains + // usable through the global catalog, so this stays a warning. + log.warn("[SkillAutoBind] Failed to bind skill '{}' (id={}) to agent={}: {}", + event.skillName(), event.skillId(), event.agentId(), e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/common/text/SecretRedactor.java b/mateclaw-server/src/main/java/vip/mate/common/text/SecretRedactor.java new file mode 100644 index 00000000..881b6fb6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/text/SecretRedactor.java @@ -0,0 +1,70 @@ +package vip.mate.common.text; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * Best-effort masking of credential-shaped substrings in free text. + * + *

Intended for text that is about to be copied out of its original + * store — persisted into a second table, rendered in an admin screen, or sent + * to a model. A secret that already sits in a conversation row is exposed + * exactly once; duplicating it into a new location multiplies the places it + * can leak from and outlives any later cleanup of the original. + * + *

Deliberately conservative: it matches shapes that are almost always + * credentials (provider key prefixes, explicit {@code key=value} assignments, + * bearer headers) rather than anything high-entropy. Over-matching would + * quietly destroy the words that make a request recognisable, and this text is + * used to tell one routine from another. This reduces exposure; it is not a + * guarantee, and it is not a substitute for keeping secrets out of chat. + * + * @author MateClaw Team + */ +public final class SecretRedactor { + + /** Replacement for any matched credential. */ + public static final String MASK = "[redacted]"; + + private static final List PATTERNS = List.of( + // Provider key prefixes: OpenAI (incl. sk-proj-), Anthropic, GitHub, + // Slack, Google, AWS access key ids. + Pattern.compile("\\bsk-[A-Za-z0-9_-]{12,}"), + Pattern.compile("\\bgh[pousr]_[A-Za-z0-9]{16,}"), + Pattern.compile("\\bxox[baprs]-[A-Za-z0-9-]{10,}"), + Pattern.compile("\\bAIza[A-Za-z0-9_-]{20,}"), + Pattern.compile("\\bAKIA[0-9A-Z]{16}\\b"), + // Authorization headers. + Pattern.compile("(?i)\\bbearer\\s+[A-Za-z0-9._~+/=-]{16,}"), + // Explicit assignments — keep the field name, mask only the value, + // so "api_key = [redacted]" still reads as what it was. + Pattern.compile("(?i)\\b(api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key" + + "|client[_-]?secret|password|passwd|token|secret)\\b\\s*[:=]\\s*" + + "[\"']?[^\\s\"',;]{6,}[\"']?") + ); + + /** Index of the field-name group in the assignment pattern above. */ + private static final int ASSIGNMENT_PATTERN_INDEX = PATTERNS.size() - 1; + + private SecretRedactor() { + } + + /** + * Mask credential-shaped substrings. + * + * @param text input; {@code null} is returned unchanged + * @return the text with credentials replaced by {@link #MASK} + */ + public static String redact(String text) { + if (text == null || text.isEmpty()) { + return text; + } + String out = text; + for (int i = 0; i < PATTERNS.size(); i++) { + out = i == ASSIGNMENT_PATTERN_INDEX + ? PATTERNS.get(i).matcher(out).replaceAll("$1=" + MASK) + : PATTERNS.get(i).matcher(out).replaceAll(MASK); + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/common/text/Shingles.java b/mateclaw-server/src/main/java/vip/mate/common/text/Shingles.java new file mode 100644 index 00000000..acd89ba7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/text/Shingles.java @@ -0,0 +1,85 @@ +package vip.mate.common.text; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Language-agnostic near-duplicate text comparison without a word segmenter. + * + *

A shingle set mixes Latin word tokens with CJK character bigrams, so the + * same routine works on space-delimited English and on space-free Chinese. + * Bigrams are the reason a segmenter is unnecessary: two Chinese sentences + * that share most of their characters in the same order share most of their + * bigrams, while unrelated sentences of similar length do not. + * + *

Extracted so relevance scoring (memory recall) and recurrence detection + * (routine mining) agree on what "these two texts say the same thing" means. + * Callers should lowercase the input first when case should be ignored — the + * Latin token pattern only matches lowercase. + * + * @author MateClaw Team + */ +public final class Shingles { + + /** Latin word tokens; two chars minimum so single letters do not dominate. */ + private static final Pattern WORD_RE = Pattern.compile("[a-z0-9]{2,}"); + + private Shingles() { + } + + /** + * Produce the shingle set: Latin word tokens (length >= 2) plus CJK + * character bigrams (a single CJK character when isolated). + * + * @param text input; {@code null} yields an empty set + */ + public static Set of(String text) { + Set out = new HashSet<>(); + if (text == null || text.isEmpty()) { + return out; + } + + Matcher m = WORD_RE.matcher(text); + while (m.find()) { + out.add(m.group()); + } + + for (String run : text.replaceAll("[^\\p{IsHan}]", " ").split("\\s+")) { + if (run.isEmpty()) continue; + if (run.length() == 1) { + out.add(run); + } else { + for (int i = 0; i + 2 <= run.length(); i++) { + out.add(run.substring(i, i + 2)); + } + } + } + + return out; + } + + /** + * Jaccard similarity of two shingle sets: {@code |A ∩ B| / |A ∪ B|}. + * + * @return {@code 0.0} when either set is empty, otherwise a value in + * {@code [0.0, 1.0]} where 1.0 means identical shingle sets + */ + public static double jaccard(Set a, Set b) { + if (a == null || b == null || a.isEmpty() || b.isEmpty()) { + return 0.0; + } + // Intersect against the smaller set so the scan is bounded by it. + Set smaller = a.size() <= b.size() ? a : b; + Set larger = smaller == a ? b : a; + int intersection = 0; + for (String s : smaller) { + if (larger.contains(s)) { + intersection++; + } + } + int union = a.size() + b.size() - intersection; + return union == 0 ? 0.0 : (double) intersection / union; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java index a5f10954..5e8cb541 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.common.text.Shingles; import vip.mate.memory.event.MemoryWriteEvent; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; @@ -63,9 +64,6 @@ public class StructuredMemoryService { */ public static final String PROJECT_RECALLED_MARKER = "includes the user's current project"; - /** Latin word tokens of length >= 2 used for relevance shingling. */ - private static final Pattern WORD_RE = Pattern.compile("[a-z0-9]{2,}"); - /** Captures the ISO update date from an entry's metadata line ("> ... | Updated: YYYY-MM-DD"). */ private static final Pattern UPDATED_RE = Pattern.compile("Updated:\\s*(\\d{4}-\\d{2}-\\d{2})"); @@ -469,30 +467,13 @@ public class StructuredMemoryService { } /** - * Produce a language-agnostic shingle set: Latin word tokens (length >= 2) - * plus CJK character bigrams (single CJK characters when isolated). This lets - * relevance scoring work without a word segmenter on space-free CJK text. + * Language-agnostic shingle set (Latin word tokens + CJK character + * bigrams). Delegates to {@link Shingles} so relevance scoring here and + * recurrence detection in routine mining share one definition of + * "these two texts say the same thing". */ private static Set shingles(String text) { - Set out = new HashSet<>(); - - Matcher m = WORD_RE.matcher(text); - while (m.find()) { - out.add(m.group()); - } - - for (String run : text.replaceAll("[^\\p{IsHan}]", " ").split("\\s+")) { - if (run.isEmpty()) continue; - if (run.length() == 1) { - out.add(run); - } else { - for (int i = 0; i + 2 <= run.length(); i++) { - out.add(run.substring(i, i + 2)); - } - } - } - - return out; + return Shingles.of(text); } private String toFilename(String type) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index a6981e99..c2075a7f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -34,9 +34,13 @@ import vip.mate.exception.MateClawException; import vip.mate.skill.lifecycle.ConfirmRequiredException; import vip.mate.skill.lifecycle.LifecycleTransition; import vip.mate.skill.lifecycle.SkillCuratorJob; +import vip.mate.skill.lifecycle.SkillSnapshotService; +import vip.mate.skill.routine.SkillRoutineMiner; +import vip.mate.skill.routine.SkillRoutineService; import vip.mate.skill.lifecycle.SkillCuratorReport; import vip.mate.skill.lifecycle.SkillCuratorReportStore; import vip.mate.skill.lifecycle.SkillLifecycleService; +import vip.mate.skill.lifecycle.model.SkillSnapshotEntity; import java.time.LocalDateTime; import java.util.ArrayList; @@ -76,6 +80,9 @@ public class SkillController { private final SkillLifecycleService skillLifecycleService; private final SkillCuratorJob skillCuratorJob; private final SkillCuratorReportStore skillCuratorReportStore; + private final SkillSnapshotService skillSnapshotService; + private final SkillRoutineService skillRoutineService; + private final SkillRoutineMiner skillRoutineMiner; private final SkillFileService skillFileService; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @@ -1082,6 +1089,112 @@ public class SkillController { return R.ok(skillCuratorJob.status()); } + // ==================== Routine mining ==================== + + @Operation(summary = "列出挖掘到的高频请求(例行事项)候选") + @GetMapping("/routines") + @RequireWorkspaceRole("member") + public R> routineList( + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "50") int limit) { + Map out = new LinkedHashMap<>(); + out.put("items", skillRoutineService.list(status, limit)); + out.put("gates", skillRoutineService.gates()); + return R.ok(out); + } + + @Operation(summary = "立即运行一次例行事项挖掘") + @PostMapping("/routines/mine") + @RequireWorkspaceRole("admin") + public R> routineMine() { + Map out = new LinkedHashMap<>(); + out.put("refreshed", skillRoutineMiner.mine()); + return R.ok(out); + } + + @Operation(summary = "忽略某个例行事项候选(后续挖掘不再重开)") + @PostMapping("/routines/{id}/dismiss") + @RequireWorkspaceRole("admin") + public R> routineDismiss(@PathVariable String id) { + return R.ok(skillRoutineService.dismiss(parseRoutineId(id))); + } + + @Operation(summary = "重新观察一个已忽略的例行事项候选") + @PostMapping("/routines/{id}/reopen") + @RequireWorkspaceRole("admin") + public R> routineReopen(@PathVariable String id) { + return R.ok(skillRoutineService.reopen(parseRoutineId(id))); + } + + @Operation(summary = "立即把例行事项候选合成为技能(跳过频次门槛)") + @PostMapping("/routines/{id}/promote") + @RequireWorkspaceRole("admin") + public R> routinePromote(@PathVariable String id) { + try { + return R.ok(skillRoutineService.promoteNow(parseRoutineId(id))); + } catch (IllegalStateException e) { + throw new MateClawException("err.skill.routine_already_promoted", 409, e.getMessage()); + } + } + + /** + * Path variables stay strings end-to-end (19-digit snowflake ids lose + * precision as JS numbers); parse once here so a bad id is a clean 400. + */ + private long parseRoutineId(String id) { + try { + return Long.parseLong(id == null ? "" : id.strip()); + } catch (NumberFormatException e) { + throw new MateClawException("err.skill.routine_not_found", 400, "Invalid routine id: " + id); + } + } + + @Operation(summary = "列出技能库还原点") + @GetMapping("/curator/snapshots") + @RequireWorkspaceRole("member") + public R>> curatorSnapshots() { + return R.ok(skillSnapshotService.list(20)); + } + + @Operation(summary = "手动捕获一个技能库还原点") + @PostMapping("/curator/snapshots") + @RequireWorkspaceRole("admin") + public R> curatorSnapshotCapture( + @RequestParam(required = false) String reason) { + SkillSnapshotEntity snapshot = skillSnapshotService.capture( + reason == null || reason.isBlank() ? "manual" : reason); + if (snapshot == null) { + throw new MateClawException("err.skill.snapshot_unavailable", 400, + "Snapshot not captured — backups are disabled or there are no skills to capture"); + } + Map out = new LinkedHashMap<>(); + // Snowflake id as a string: 19 digits exceed JS Number precision. + out.put("id", String.valueOf(snapshot.getId())); + out.put("reason", snapshot.getReason()); + out.put("skillCount", snapshot.getSkillCount()); + return R.ok(out); + } + + @Operation(summary = "将技能库回滚到指定还原点") + @PostMapping("/curator/snapshots/{snapshotId}/restore") + @RequireWorkspaceRole("admin") + public R> curatorSnapshotRestore(@PathVariable String snapshotId) { + // Path variable stays a String end-to-end; parsing happens once here so + // a malformed id is a 400 rather than a framework-level failure. + long id; + try { + id = Long.parseLong(snapshotId.strip()); + } catch (NumberFormatException e) { + throw new MateClawException("err.skill.snapshot_not_found", 400, + "Invalid snapshot id: " + snapshotId); + } + try { + return R.ok(skillSnapshotService.restore(id)); + } catch (IllegalArgumentException e) { + throw new MateClawException("err.skill.snapshot_not_found", 404, e.getMessage()); + } + } + @Operation(summary = "列出最近的 curator 运行报告") @GetMapping("/curator/reports") @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/event/SkillAuthoredEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/event/SkillAuthoredEvent.java new file mode 100644 index 00000000..f337cd03 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/event/SkillAuthoredEvent.java @@ -0,0 +1,35 @@ +package vip.mate.skill.event; + +/** + * Fires after an agent authored a brand-new skill for itself during a + * conversation (the {@code skill_manage create} path), carrying the authoring + * agent so downstream listeners can react to "this agent just learned + * something". + * + *

Distinct from {@link SkillUpdatedEvent}, which covers every write to an + * existing row regardless of origin. This event fires only on creation and + * only when the write came from an agent turn, so it carries the one piece of + * context {@code SkillService} cannot see: which agent was talking. + * + *

The primary consumer is the auto-bind listener in the {@code agent} + * layer: a self-authored skill is useless if the authoring agent's own + * catalog cannot see it, which is exactly what happens when the agent runs + * with an explicit skill allowlist. Publishing an event (rather than calling + * the binding service directly from the tool) keeps the dependency direction + * {@code agent → skill} intact and avoids a circular bean graph, matching the + * reasoning already documented on {@link SkillUpdatedEvent}. + * + * @param skillId DB id of the newly created skill row + * @param skillName slug identifier the row carries, useful for log lines + * @param agentId agent that authored the skill; {@code null} when the + * write had no agent origin (e.g. a REST call) + * @param conversationId conversation the skill was distilled from, or + * {@code null} when unknown + * @param workspaceId workspace the new skill row was stamped with + */ +public record SkillAuthoredEvent(Long skillId, + String skillName, + Long agentId, + String conversationId, + Long workspaceId) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java index e35f4ba6..76dcdbc4 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java @@ -17,6 +17,7 @@ import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillOrigin; import vip.mate.skill.service.SkillService; import vip.mate.tool.builtin.SkillManageTool; @@ -139,7 +140,8 @@ public class SkillConsolidationService { ToolContext ctx = toolContext(lineageConv); String act = willCreate ? "create" : "edit"; - String result = skillManageTool.skill_manage(act, umbrellaName, umbrellaContent, null, null, null, ctx); + String result = skillManageTool.skillManageAs(SkillOrigin.AGENT, act, umbrellaName, + umbrellaContent, null, null, null, ctx); boolean umbrellaOk = result != null && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED"); if (!umbrellaOk) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java index a5bb7c8f..d0705b84 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java @@ -10,6 +10,7 @@ import org.springframework.scheduling.support.CronExpression; import org.springframework.stereotype.Component; import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillOrigin; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.workspace.SkillWorkspaceManager; import vip.mate.system.service.SystemSettingService; @@ -60,6 +61,7 @@ public class SkillCuratorJob { private final SkillWorkspaceManager workspaceManager; private final CuratorRunNotifier notifier; private final SkillConsolidationService consolidationService; + private final SkillSnapshotService snapshotService; @Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}") @SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") @@ -191,6 +193,18 @@ public class SkillCuratorJob { .config(properties.getStaleAfterDays(), properties.getArchiveAfterDays(), properties.getScope()); + // Capture a restore point before anything mutates. A dry run changes + // nothing, so it needs none; a real sweep can archive and (with + // consolidation on) rewrite skill bodies unattended, and this is the + // only chance to record what they looked like beforehand. + if (!dryRun) { + try { + snapshotService.capture("pre-sweep"); + } catch (Exception e) { + log.warn("Pre-sweep snapshot failed, continuing: {}", e.getMessage()); + } + } + reconcileOrphans(now, report, dryRun); List candidates = loadCandidates(); @@ -241,7 +255,12 @@ public class SkillCuratorJob { /** * Candidate skills for the state machine: not builtin, not pinned, not a * builtin/mcp/acp type, not bound to any enabled agent, and — under the - * default {@code AGENT_CREATED} scope — created by an agent. + * default {@code AGENT_CREATED} scope — written autonomously. + * + *

The scope filter keys on {@code origin}, not on the presence of a + * source conversation. Both a skill the user asked for mid-chat and one + * the background reviewer invented carry a conversation id, so the older + * filter swept up user-requested work alongside the machine's own. */ private List loadCandidates() { Set bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents(); @@ -254,7 +273,7 @@ public class SkillCuratorJob { w.notIn(SkillEntity::getId, bindingProtected); } if ("AGENT_CREATED".equals(properties.getScope())) { - w.isNotNull(SkillEntity::getSourceConversationId); + w.in(SkillEntity::getOrigin, SkillOrigin.curatorManagedCodes()); } return skillMapper.selectList(w); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java index 7c49755d..0502144c 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java @@ -61,4 +61,14 @@ public class SkillLifecycleProperties { /** Consolidation model ID ({@code null} = follow the system default model). */ private String consolidateModelId; + + /** + * Whether a restore point is captured before each mutating sweep. Gates + * both the automatic pre-sweep capture and the manual one, so there is no + * configuration in which a mutating run silently skips its snapshot. + */ + private boolean backupEnabled = true; + + /** Restore points retained; older ones are pruned after each capture. */ + private int backupKeep = 5; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillSnapshotService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillSnapshotService.java new file mode 100644 index 00000000..9314696c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillSnapshotService.java @@ -0,0 +1,226 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.skill.lifecycle.model.SkillSnapshotEntity; +import vip.mate.skill.lifecycle.repository.SkillSnapshotMapper; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Restore points for the skill library, captured before a mutating curator + * sweep. + * + *

Autonomous curation makes broad, unattended changes: consolidation + * rewrites skill bodies and folds several skills into an umbrella, and the + * state machine archives skills out of the active set. Both run overnight with + * nobody watching, so the first time anyone notices a bad pass is well after + * it finished. A snapshot turns "the curator mangled my library" from an + * unrecoverable event into one command. + * + *

Only the fields autonomous curation can actually change are captured. + * The curator never deletes a skill — it archives, which is a state change — + * so restoring is always an update over rows that still exist, never a + * resurrection. + * + *

Restore is itself snapshotted first, so a rollback applied to the wrong + * run can be rolled forward again. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillSnapshotService { + + private final SkillMapper skillMapper; + private final SkillSnapshotMapper snapshotMapper; + private final SkillLifecycleProperties properties; + private final ObjectMapper objectMapper; + + private static final DateTimeFormatter LABEL_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * Capture the current state of every curatable skill. + * + * @param reason why the snapshot was taken, shown in listings + * @return the persisted snapshot, or {@code null} when snapshots are + * disabled or there was nothing to capture + */ + public SkillSnapshotEntity capture(String reason) { + if (!properties.isBackupEnabled()) { + return null; + } + List skills = skillMapper.selectList( + new LambdaQueryWrapper().eq(SkillEntity::getBuiltin, false)); + if (skills == null || skills.isEmpty()) { + return null; + } + ArrayNode payload = objectMapper.createArrayNode(); + for (SkillEntity skill : skills) { + payload.add(toNode(skill)); + } + SkillSnapshotEntity snapshot = new SkillSnapshotEntity(); + snapshot.setReason(reason == null || reason.isBlank() ? "manual" : reason.strip()); + snapshot.setSkillCount(skills.size()); + try { + snapshot.setPayload(objectMapper.writeValueAsString(payload)); + snapshotMapper.insert(snapshot); + } catch (Exception e) { + log.warn("[SkillSnapshot] Capture failed ({}): {}", reason, e.getMessage()); + return null; + } + pruneToRetention(); + log.info("[SkillSnapshot] Captured {} skill(s) — reason='{}', id={}", + skills.size(), snapshot.getReason(), snapshot.getId()); + return snapshot; + } + + /** + * Roll the skill library back to a snapshot. + * + *

Takes a {@code pre-restore} snapshot first, so an unwanted rollback + * can be undone by restoring that one. + * + * @param snapshotId snapshot to restore + * @return per-skill outcome counts + * @throws IllegalArgumentException when the snapshot does not exist or its + * payload cannot be read + */ + public Map restore(Long snapshotId) { + SkillSnapshotEntity snapshot = snapshotMapper.selectById(snapshotId); + if (snapshot == null) { + throw new IllegalArgumentException("Snapshot " + snapshotId + " not found"); + } + JsonNode payload; + try { + payload = objectMapper.readTree(snapshot.getPayload() == null ? "[]" : snapshot.getPayload()); + } catch (Exception e) { + throw new IllegalArgumentException("Snapshot " + snapshotId + " payload is unreadable", e); + } + if (!payload.isArray()) { + throw new IllegalArgumentException("Snapshot " + snapshotId + " payload is not an array"); + } + + // Snapshot the current state before overwriting it, so restoring the + // wrong run is not itself a one-way door. + capture("pre-restore to snapshot " + snapshotId); + + int restored = 0; + int missing = 0; + for (JsonNode node : payload) { + Long id = node.path("id").isNull() ? null : node.path("id").asLong(0); + if (id == null || id == 0) { + continue; + } + if (skillMapper.selectById(id) == null) { + // The curator never deletes, so a row that is gone was removed + // by something else; re-creating it here would resurrect a + // deletion the user meant. + missing++; + continue; + } + try { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, id) + .set(SkillEntity::getSkillContent, textOrNull(node, "skillContent")) + .set(SkillEntity::getDescription, textOrNull(node, "description")) + .set(SkillEntity::getVersion, textOrNull(node, "version")) + .set(SkillEntity::getTags, textOrNull(node, "tags")) + .set(SkillEntity::getOrigin, textOrNull(node, "origin")) + .set(SkillEntity::getLifecycleState, textOrNull(node, "lifecycleState")) + .set(SkillEntity::getEnabled, boolOrNull(node, "enabled")) + .set(SkillEntity::getPinned, boolOrNull(node, "pinned"))); + restored++; + } catch (Exception e) { + log.warn("[SkillSnapshot] Restore failed for skill id={}: {}", id, e.getMessage()); + } + } + log.info("[SkillSnapshot] Restored {} skill(s) from snapshot {} ({} no longer present)", + restored, snapshotId, missing); + Map out = new LinkedHashMap<>(); + out.put("snapshotId", String.valueOf(snapshotId)); + out.put("restored", restored); + out.put("missing", missing); + return out; + } + + /** Recent snapshots, newest first, without their payloads. */ + public List> list(int limit) { + List rows = snapshotMapper.selectList( + new LambdaQueryWrapper() + .select(SkillSnapshotEntity::getId, SkillSnapshotEntity::getReason, + SkillSnapshotEntity::getSkillCount, SkillSnapshotEntity::getCreateTime) + .orderByDesc(SkillSnapshotEntity::getCreateTime) + .last("LIMIT " + Math.max(1, limit))); + List> out = new ArrayList<>(); + for (SkillSnapshotEntity row : rows) { + Map m = new LinkedHashMap<>(); + // Snowflake id as a string: 19 digits exceed JS Number precision. + m.put("id", String.valueOf(row.getId())); + m.put("reason", row.getReason()); + m.put("skillCount", row.getSkillCount()); + m.put("createdAt", row.getCreateTime() == null ? null : row.getCreateTime().format(LABEL_FMT)); + out.add(m); + } + return out; + } + + /** Drop the oldest snapshots beyond the configured retention count. */ + private void pruneToRetention() { + int keep = Math.max(1, properties.getBackupKeep()); + List rows = snapshotMapper.selectList( + new LambdaQueryWrapper() + .select(SkillSnapshotEntity::getId) + .orderByDesc(SkillSnapshotEntity::getCreateTime)); + if (rows.size() <= keep) { + return; + } + for (SkillSnapshotEntity stale : rows.subList(keep, rows.size())) { + try { + snapshotMapper.deleteById(stale.getId()); + } catch (Exception e) { + log.debug("[SkillSnapshot] Prune failed for {}: {}", stale.getId(), e.getMessage()); + } + } + } + + private ObjectNode toNode(SkillEntity skill) { + ObjectNode n = objectMapper.createObjectNode(); + n.put("id", skill.getId()); + n.put("name", skill.getName()); + n.put("description", skill.getDescription()); + n.put("version", skill.getVersion()); + n.put("tags", skill.getTags()); + n.put("origin", skill.getOrigin()); + n.put("lifecycleState", skill.getLifecycleState()); + n.put("enabled", skill.getEnabled()); + n.put("pinned", skill.getPinned()); + n.put("skillContent", skill.getSkillContent()); + return n; + } + + private static String textOrNull(JsonNode node, String field) { + JsonNode v = node.get(field); + return v == null || v.isNull() ? null : v.asText(); + } + + private static Boolean boolOrNull(JsonNode node, String field) { + JsonNode v = node.get(field); + return v == null || v.isNull() ? null : v.asBoolean(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/model/SkillSnapshotEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/model/SkillSnapshotEntity.java new file mode 100644 index 00000000..0078042c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/model/SkillSnapshotEntity.java @@ -0,0 +1,43 @@ +package vip.mate.skill.lifecycle.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * A restore point for the skill library, captured before a mutating curator + * sweep. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_skill_snapshot") +public class SkillSnapshotEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Why the snapshot was taken — {@code pre-sweep}, {@code pre-restore}, or a manual note. */ + private String reason; + + /** Number of skills captured, so a listing need not parse the payload. */ + private Integer skillCount; + + /** JSON array of the captured skill rows. */ + @TableField(value = "payload", updateStrategy = FieldStrategy.ALWAYS) + private String payload; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/repository/SkillSnapshotMapper.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/repository/SkillSnapshotMapper.java new file mode 100644 index 00000000..f8f3c3bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/repository/SkillSnapshotMapper.java @@ -0,0 +1,14 @@ +package vip.mate.skill.lifecycle.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.skill.lifecycle.model.SkillSnapshotEntity; + +/** + * Data access for skill library restore points. + * + * @author MateClaw Team + */ +@Mapper +public interface SkillSnapshotMapper extends BaseMapper { +} 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 9ab88d18..09be80da 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 @@ -110,6 +110,22 @@ public class SkillEntity { /** 来源对话 ID(Agent 自治合成时记录) */ private String sourceConversationId; + /** + * Authorship as a curation policy flag: {@code user} (requested by a + * person in a foreground conversation or via the admin UI — off-limits to + * autonomous curation), {@code agent} (written by the reflection + * reviewer), or {@code routine} (written by routine mining). + * + *

Distinct from {@link #sourceConversationId}, which only records + * where a skill came from. Both a user-requested skill and an + * autonomously-authored one carry a conversation id, so that field alone + * cannot tell the curator which skills it may age out. + * + * @see SkillOrigin + */ + @TableField(value = "origin", updateStrategy = FieldStrategy.ALWAYS) + private String origin; + /** * RFC-023:安全扫描状态。 * NULL = 旧数据或手动创建(不受扫描约束),PASSED = 扫描通过,FAILED = 扫描拦截。 diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillOrigin.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillOrigin.java new file mode 100644 index 00000000..1fe2f55e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillOrigin.java @@ -0,0 +1,57 @@ +package vip.mate.skill.model; + +import java.util.List; + +/** + * Who authored a skill, read as a policy flag: may autonomous curation + * mutate this skill? + * + *

The distinction that matters is not "which code path wrote the row" but + * "was a user present and asking for it". A skill the user requested in a live + * conversation is theirs — aging it out on the same clock as one the system + * invented on its own would delete work they deliberately asked for. A skill + * written by the background reviewer or the routine miner has no such standing: + * nobody asked for it, so nobody is surprised when it expires unused. + * + *

Deliberately not inferred. Usage telemetry cannot establish authorship — + * a heavily-patched skill proves the agent maintains it, not that the agent + * wrote it — so the value is stamped at write time by the caller that knows, + * and never guessed afterwards. + * + * @author MateClaw Team + */ +public enum SkillOrigin { + + /** + * Authored in a foreground conversation at the user's request, or created + * through the admin UI. Off-limits to autonomous curation. + */ + USER("user"), + + /** Authored by the out-of-band reflection reviewer. Curator-managed. */ + AGENT("agent"), + + /** Authored by routine mining from a recurring request. Curator-managed. */ + ROUTINE("routine"); + + private final String code; + + SkillOrigin(String code) { + this.code = code; + } + + /** Persisted column value. */ + public String code() { + return code; + } + + /** Whether autonomous curation may age or rewrite skills of this origin. */ + public boolean curatorManaged() { + return this != USER; + } + + /** Column values the curator is allowed to touch. */ + public static List curatorManagedCodes() { + return List.of(AGENT.code, ROUTINE.code); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java index a3710cf4..8b5a47a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java @@ -20,12 +20,14 @@ import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.memory.event.ConversationCompletedEvent; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillOrigin; import vip.mate.skill.service.SkillService; import vip.mate.tool.builtin.SkillManageTool; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageEntity; import java.time.Instant; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -57,8 +59,27 @@ public class SkillReflectionService { private final SkillReflectionProperties properties; private final ObjectMapper objectMapper; - /** Per-conversation cooldown tracking. */ - private final ConcurrentHashMap lastRunTimes = new ConcurrentHashMap<>(); + /** + * Per-conversation review bookkeeping: when the last review ran (cooldown) + * and the message count it ran at (cadence high-water mark). + * + * @param lastRunAt wall-clock time of the last attempted review + * @param reviewedAtMessage conversation message count at that attempt + */ + private record ReviewState(Instant lastRunAt, int reviewedAtMessage) { + } + + /** Per-conversation cadence + cooldown tracking. */ + private final ConcurrentHashMap reviewStates = new ConcurrentHashMap<>(); + + /** + * Cap on tracked conversations. The map is a cadence accelerator, not a + * source of truth — dropping the oldest entries only means those + * conversations get one extra review opportunity, so a coarse eviction is + * enough to keep a long-lived server from accumulating one entry per + * conversation forever. + */ + private static final int MAX_TRACKED_CONVERSATIONS = 2000; /** Per-message truncation when building the review transcript. */ private static final int MESSAGE_TRUNCATE_CHARS = 1200; @@ -83,19 +104,34 @@ public class SkillReflectionService { if (!properties.isEnabled() || agentId == null || conversationId == null) { return; } - // Cadence gate: review every N messages. - if (properties.getReviewTurnInterval() <= 0 - || messageCount % properties.getReviewTurnInterval() != 0) { + int interval = properties.getReviewTurnInterval(); + if (interval <= 0) { return; } - if (isInCooldown(conversationId)) { + // Cadence gate: at least N new messages since the last attempt. + // Deliberately a high-water mark rather than `messageCount % interval` + // — the count is the conversation total at publish time and can jump by + // more than one per event (batched persistence, tool messages, channel + // replays), so an exact-multiple test silently skips whole review + // opportunities whenever it steps over the multiple. + ReviewState state = reviewStates.get(conversationId); + int reviewedAt = state == null ? 0 : state.reviewedAtMessage(); + if (messageCount - reviewedAt < interval) { + return; + } + if (isInCooldown(state)) { log.debug("[SkillReflect] conversation {} in cooldown, skipping", conversationId); return; } + // Advance the mark on every attempt the gate lets through, including + // ones that bail on the substance floor. A conversation that stays + // below the floor should wait another full interval rather than + // re-checking on every subsequent message. + evictIfOversized(); + reviewStates.put(conversationId, new ReviewState(Instant.now(), messageCount)); try { - boolean ran = doReflect(agentId, conversationId); - if (ran) { - lastRunTimes.put(conversationId, Instant.now()); + if (!doReflect(agentId, conversationId)) { + log.debug("[SkillReflect] conv {} yielded no review this cycle", conversationId); } } catch (Exception e) { log.warn("[SkillReflect] Failed for agent={}, conv={}: {}", @@ -192,7 +228,8 @@ public class SkillReflectionService { String oldText = action.path("oldText").asText(null); String newText = action.path("newText").asText(null); try { - String result = skillManageTool.skill_manage(act, name, content, oldText, newText, null, toolContext); + String result = skillManageTool.skillManageAs(SkillOrigin.AGENT, act, name, content, + oldText, newText, null, toolContext); boolean ok = result != null && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED"); if (ok) { log.info("[SkillReflect] {} '{}' — {}", act, name, @@ -334,13 +371,29 @@ public class SkillReflectionService { || msg.contains("Too Many Requests")); } - private boolean isInCooldown(String conversationId) { - Instant lastRun = lastRunTimes.get(conversationId); - if (lastRun == null) { + private boolean isInCooldown(ReviewState state) { + if (state == null || state.lastRunAt() == null) { return false; } long cooldownSeconds = properties.getCooldownMinutes() * 60L; - return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds)); + return Instant.now().isBefore(state.lastRunAt().plusSeconds(cooldownSeconds)); + } + + /** + * Drop the least-recently-reviewed entries once the tracking map grows past + * {@link #MAX_TRACKED_CONVERSATIONS}, so a long-running server does not + * retain one entry per conversation for its whole uptime. + */ + private void evictIfOversized() { + if (reviewStates.size() < MAX_TRACKED_CONVERSATIONS) { + return; + } + reviewStates.entrySet().stream() + .sorted(Comparator.comparing(e -> e.getValue().lastRunAt())) + .limit(Math.max(1, MAX_TRACKED_CONVERSATIONS / 4)) + .map(Map.Entry::getKey) + .toList() + .forEach(reviewStates::remove); } private static String truncate(String s, int maxLen) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineAutoConfiguration.java new file mode 100644 index 00000000..1c40e0c5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineAutoConfiguration.java @@ -0,0 +1,14 @@ +package vip.mate.skill.routine; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Registers configuration for routine mining. + * + * @author MateClaw Team + */ +@Configuration +@EnableConfigurationProperties(SkillRoutineProperties.class) +public class SkillRoutineAutoConfiguration { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineJob.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineJob.java new file mode 100644 index 00000000..6ee8e3b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineJob.java @@ -0,0 +1,46 @@ +package vip.mate.skill.routine; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Nightly sweep that mines recurring requests and promotes the qualified ones + * into skills. + * + *

Scheduled an hour after the lifecycle curator so the two never contend + * for the same skill rows: the curator ages skills out, this job writes new + * ones, and interleaving them within one window would let a freshly promoted + * routine meet the archival sweep before it has ever been used. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillRoutineJob { + + private final SkillRoutineMiner miner; + private final SkillRoutinePromoter promoter; + private final SkillRoutineProperties properties; + + @Scheduled(cron = "${mateclaw.skill.routine.cron:0 0 3 * * *}") + @SchedulerLock(name = "skill-routine", lockAtMostFor = "PT20M", lockAtLeastFor = "PT30S") + public void run() { + if (!properties.isEnabled()) { + return; + } + try { + int mined = miner.mine(); + int promoted = promoter.promoteQualified(); + if (mined > 0 || promoted > 0) { + log.info("[SkillRoutine] Sweep complete — {} candidate(s) refreshed, {} promoted", + mined, promoted); + } + } catch (Exception e) { + log.warn("[SkillRoutine] Sweep failed: {}", e.getMessage(), e); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineMiner.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineMiner.java new file mode 100644 index 00000000..90daaa27 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineMiner.java @@ -0,0 +1,395 @@ +package vip.mate.skill.routine; + +import cn.hutool.crypto.SecureUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.common.text.SecretRedactor; +import vip.mate.common.text.Shingles; +import vip.mate.skill.routine.model.SkillRoutineCandidateEntity; +import vip.mate.skill.routine.repository.SkillRoutineCandidateMapper; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Detects requests the user makes habitually, by clustering the opening + * message of every recent conversation and counting how many distinct + * conversations and distinct days each cluster spans. + * + *

Why a separate pass

+ * Recurrence is structurally invisible to the post-turn reflection reviewer: + * it sees exactly one conversation window, in which a habitual request is + * indistinguishable from a one-off task. Reflection is right to decline + * writing a skill for a one-off narrative — which means the very signal the + * user cares about ("I ask this every week, just know how to do it") can never + * reach it. This pass supplies the missing dimension by looking across + * sessions, where repetition is the evidence. + * + *

Recomputed, not accumulated

+ * Every sweep recomputes each cluster's statistics from scratch over the + * lookback window and writes the result, rather than incrementing counters. + * That makes repeated sweeps idempotent (a re-run cannot inflate counts) and + * lets a routine the user abandoned decay back out of the window on its own. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillRoutineMiner { + + private final ConversationMapper conversationMapper; + private final MessageMapper messageMapper; + private final SkillRoutineCandidateMapper candidateMapper; + private final SkillRoutineProperties properties; + private final ObjectMapper objectMapper; + + /** Conversation ids per {@code IN} clause when loading openers. */ + private static final int OPENER_BATCH_SIZE = 200; + + /** URLs, filesystem paths, and long digit runs carry no routine identity. */ + private static final Pattern URL_RE = Pattern.compile("https?://\\S+"); + private static final Pattern PATH_RE = Pattern.compile("(?:[A-Za-z]:)?[/\\\\][\\w./\\\\-]{3,}"); + private static final Pattern DIGITS_RE = Pattern.compile("\\d+"); + /** Everything that is not a letter, CJK character, or space. */ + private static final Pattern NOISE_RE = Pattern.compile("[^\\p{IsHan}\\p{IsAlphabetic} ]+"); + private static final Pattern SPACE_RE = Pattern.compile("\\s+"); + + /** + * One conversation's opening request, already normalized and shingled. + * + * @param conversationId external conversation identifier + * @param agentId owning agent + * @param workspaceId owning workspace, may be {@code null} + * @param rawOpener verbatim opener, kept for the synthesis prompt + * @param normalized normalized opener; the cluster signature source + * @param shingles shingle set of {@link #normalized} + * @param seenAt when the conversation started + */ + record Opener(String conversationId, + Long agentId, + Long workspaceId, + String rawOpener, + String normalized, + Set shingles, + LocalDateTime seenAt) { + } + + /** A group of openers judged to be the same request. */ + static final class Cluster { + private final List members = new ArrayList<>(); + + Cluster(Opener seed) { + members.add(seed); + } + + Opener seed() { + return members.get(0); + } + + List members() { + return members; + } + + /** Most recent member — the freshest phrasing of the routine. */ + Opener latest() { + Opener best = members.get(0); + for (Opener o : members) { + if (o.seenAt() != null + && (best.seenAt() == null || o.seenAt().isAfter(best.seenAt()))) { + best = o; + } + } + return best; + } + + int distinctDays() { + Set days = new HashSet<>(); + for (Opener o : members) { + if (o.seenAt() != null) { + days.add(o.seenAt().toLocalDate()); + } + } + return days.size(); + } + } + + /** + * Run one mining sweep across every agent with recent activity. + * + * @return number of candidate rows written or refreshed + */ + public int mine() { + if (!properties.isEnabled()) { + return 0; + } + LocalDateTime cutoff = LocalDateTime.now().minusDays(Math.max(1, properties.getLookbackDays())); + List conversations = loadRecentConversations(cutoff); + if (conversations.isEmpty()) { + return 0; + } + Map openersByConversation = loadOpeners(conversations); + if (openersByConversation.isEmpty()) { + return 0; + } + + // Group by agent — a routine belongs to the agent the user runs it on. + Map> byAgent = new LinkedHashMap<>(); + for (ConversationEntity conv : conversations) { + if (conv.getAgentId() == null || conv.getConversationId() == null) { + continue; + } + // Redact before anything downstream keeps a copy. This text is + // persisted into the candidate table, rendered in the admin list, + // and sent to the synthesis model — three new places a credential + // pasted into a chat would otherwise come to rest. + String raw = SecretRedactor.redact(openersByConversation.get(conv.getConversationId())); + String normalized = normalize(raw); + if (normalized.length() < properties.getMinOpenerChars()) { + continue; + } + Set shingles = Shingles.of(normalized); + if (shingles.isEmpty()) { + continue; + } + byAgent.computeIfAbsent(conv.getAgentId(), k -> new ArrayList<>()) + .add(new Opener(conv.getConversationId(), conv.getAgentId(), conv.getWorkspaceId(), + raw, normalized, shingles, conversationStart(conv))); + } + + int written = 0; + for (Map.Entry> entry : byAgent.entrySet()) { + for (Cluster cluster : cluster(entry.getValue())) { + if (cluster.members().size() < 2) { + // A singleton carries no recurrence evidence; persisting it + // would fill the table with one row per conversation. + continue; + } + if (upsert(entry.getKey(), cluster)) { + written++; + } + } + } + if (written > 0) { + log.info("[SkillRoutine] Mining sweep refreshed {} candidate(s) across {} agent(s)", + written, byAgent.size()); + } + return written; + } + + // ==================== Loading ==================== + + private List loadRecentConversations(LocalDateTime cutoff) { + Page page = new Page<>(1, Math.max(1, properties.getMaxConversationsPerRun()), false); + LambdaQueryWrapper q = new LambdaQueryWrapper() + .select(ConversationEntity::getConversationId, ConversationEntity::getAgentId, + ConversationEntity::getWorkspaceId, ConversationEntity::getCreateTime, + ConversationEntity::getLastActiveTime) + .isNotNull(ConversationEntity::getAgentId) + .ge(ConversationEntity::getLastActiveTime, cutoff) + .orderByDesc(ConversationEntity::getLastActiveTime); + return conversationMapper.selectPage(page, q).getRecords(); + } + + /** + * First user message of each conversation, keyed by conversation id. + * + *

Loads user messages in batched {@code IN} clauses and keeps the + * lowest-id row per conversation. Cost scales with the number of user + * messages in the scanned conversations, which the caller bounds through + * {@code maxConversationsPerRun}; this runs as a nightly sweep, not on a + * request path. + */ + private Map loadOpeners(List conversations) { + List ids = new ArrayList<>(); + for (ConversationEntity c : conversations) { + if (c.getConversationId() != null) { + ids.add(c.getConversationId()); + } + } + Map openers = new HashMap<>(); + for (int i = 0; i < ids.size(); i += OPENER_BATCH_SIZE) { + List batch = ids.subList(i, Math.min(ids.size(), i + OPENER_BATCH_SIZE)); + List rows; + try { + rows = messageMapper.selectList(new LambdaQueryWrapper() + .select(MessageEntity::getConversationId, MessageEntity::getContent) + .eq(MessageEntity::getRole, "user") + .in(MessageEntity::getConversationId, batch) + .orderByAsc(MessageEntity::getId)); + } catch (Exception e) { + log.warn("[SkillRoutine] Opener batch load failed: {}", e.getMessage()); + continue; + } + for (MessageEntity m : rows) { + if (m.getConversationId() == null || m.getContent() == null) { + continue; + } + // Ascending id, so the first row seen per conversation is its opener. + openers.putIfAbsent(m.getConversationId(), m.getContent()); + } + } + return openers; + } + + private static LocalDateTime conversationStart(ConversationEntity conv) { + return conv.getCreateTime() != null ? conv.getCreateTime() : conv.getLastActiveTime(); + } + + // ==================== Normalization + clustering ==================== + + /** + * Strip everything that varies between two runs of the same routine — + * URLs, paths, numbers, punctuation, case — leaving the stable intent. + * "generate the 2026-08-04 report" and "generate the 2026-08-05 report" + * must normalize to the same text or they will never cluster. + */ + String normalize(String raw) { + if (raw == null || raw.isBlank()) { + return ""; + } + String text = raw.strip(); + int max = Math.max(20, properties.getMaxOpenerChars()); + if (text.length() > max) { + text = text.substring(0, max); + } + text = URL_RE.matcher(text).replaceAll(" "); + text = PATH_RE.matcher(text).replaceAll(" "); + text = DIGITS_RE.matcher(text).replaceAll(" "); + text = text.toLowerCase(); + text = NOISE_RE.matcher(text).replaceAll(" "); + return SPACE_RE.matcher(text).replaceAll(" ").strip(); + } + + /** + * Greedy single-pass clustering against each existing cluster's seed. + * + *

Seed comparison (rather than full linkage) keeps clusters tight: a + * chain of pairwise-similar openers cannot drift into one blob where the + * first and last members share nothing. + */ + List cluster(List openers) { + List clusters = new ArrayList<>(); + double threshold = properties.getSimilarityThreshold(); + for (Opener opener : openers) { + Cluster match = null; + double best = threshold; + for (Cluster c : clusters) { + double score = Shingles.jaccard(opener.shingles(), c.seed().shingles()); + if (score >= best) { + best = score; + match = c; + } + } + if (match == null) { + clusters.add(new Cluster(opener)); + } else { + match.members().add(opener); + } + } + return clusters; + } + + // ==================== Persistence ==================== + + /** @return {@code true} when a row was inserted or refreshed */ + private boolean upsert(Long agentId, Cluster cluster) { + Opener seed = cluster.seed(); + Opener latest = cluster.latest(); + String signature = truncate(seed.normalized(), 512); + String hash = SecureUtil.sha256(signature); + + SkillRoutineCandidateEntity existing = candidateMapper.selectOne( + new LambdaQueryWrapper() + .eq(SkillRoutineCandidateEntity::getAgentId, agentId) + .eq(SkillRoutineCandidateEntity::getSignatureHash, hash) + .last("LIMIT 1")); + if (existing != null + && SkillRoutineCandidateEntity.STATUS_DISMISSED.equals(existing.getStatus())) { + // The operator rejected this routine; never resurrect it. + return false; + } + + SkillRoutineCandidateEntity row = existing == null ? new SkillRoutineCandidateEntity() : existing; + row.setAgentId(agentId); + row.setWorkspaceId(seed.workspaceId()); + row.setSignature(signature); + row.setSignatureHash(hash); + row.setRepresentativeText(truncate(latest.rawOpener(), 2048)); + row.setSampleConversations(serializeSamples(cluster)); + row.setOccurrenceCount(cluster.members().size()); + row.setDistinctDayCount(cluster.distinctDays()); + row.setFirstSeenAt(earliest(cluster)); + row.setLastSeenAt(latest.seenAt()); + if (row.getStatus() == null) { + row.setStatus(SkillRoutineCandidateEntity.STATUS_OBSERVING); + } + try { + if (existing == null) { + candidateMapper.insert(row); + } else { + candidateMapper.updateById(row); + } + return true; + } catch (Exception e) { + log.warn("[SkillRoutine] Candidate upsert failed for agent={} signature='{}': {}", + agentId, signature, e.getMessage()); + return false; + } + } + + private String serializeSamples(Cluster cluster) { + List ids = new ArrayList<>(); + // Newest first: the synthesis prompt should see current phrasing. + List members = new ArrayList<>(cluster.members()); + members.sort((a, b) -> { + if (a.seenAt() == null) return 1; + if (b.seenAt() == null) return -1; + return b.seenAt().compareTo(a.seenAt()); + }); + for (Opener o : members) { + if (ids.size() >= properties.getMaxSamplesPerCandidate()) { + break; + } + ids.add(o.conversationId()); + } + try { + return objectMapper.writeValueAsString(ids); + } catch (Exception e) { + return "[]"; + } + } + + private static LocalDateTime earliest(Cluster cluster) { + LocalDateTime best = null; + for (Opener o : cluster.members()) { + if (o.seenAt() != null && (best == null || o.seenAt().isBefore(best))) { + best = o.seenAt(); + } + } + return best; + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return null; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutinePromoter.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutinePromoter.java new file mode 100644 index 00000000..f69fba06 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutinePromoter.java @@ -0,0 +1,292 @@ +package vip.mate.skill.routine; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.common.text.SecretRedactor; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.skill.model.SkillOrigin; +import vip.mate.skill.routine.model.SkillRoutineCandidateEntity; +import vip.mate.skill.routine.repository.SkillRoutineCandidateMapper; +import vip.mate.tool.builtin.SkillManageTool; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Turns a qualified recurring-request cluster into a class-level skill. + * + *

The distinguishing input is plural evidence: the synthesizer sees several + * separate conversations that all served the same request, so it can describe + * the shape they share instead of narrating one of them. That is exactly what + * the single-window reflection reviewer cannot do, and it is why a routine + * skill comes out at the class level without having to be talked into it. + * + *

Every write is routed through {@link SkillManageTool} so it inherits the + * same security scan, name validation, builtin guard, and workspace export as + * the in-band agent path. Because the tool call carries a {@link ChatOrigin} + * naming the owning agent, the resulting skill is also auto-bound to that + * agent — so the routine is reachable on the agent's very next turn, which is + * the entire point of promoting it. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillRoutinePromoter { + + private final SkillRoutineCandidateMapper candidateMapper; + private final ConversationService conversationService; + private final SkillManageTool skillManageTool; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final SkillRoutineProperties properties; + private final ObjectMapper objectMapper; + + /** + * Promote up to {@code maxPromotionsPerRun} qualified candidates. + * + * @return number of candidates that produced a skill + */ + public int promoteQualified() { + if (!properties.isEnabled()) { + return 0; + } + List candidates = candidateMapper.selectList( + new LambdaQueryWrapper() + .eq(SkillRoutineCandidateEntity::getStatus, + SkillRoutineCandidateEntity.STATUS_OBSERVING) + .ge(SkillRoutineCandidateEntity::getOccurrenceCount, properties.getMinOccurrences()) + .ge(SkillRoutineCandidateEntity::getDistinctDayCount, properties.getMinDistinctDays()) + .orderByDesc(SkillRoutineCandidateEntity::getOccurrenceCount) + .last("LIMIT " + Math.max(1, properties.getMaxPromotionsPerRun()))); + if (candidates.isEmpty()) { + return 0; + } + int promoted = 0; + for (SkillRoutineCandidateEntity candidate : candidates) { + try { + if (promote(candidate)) { + promoted++; + } + } catch (Exception e) { + log.warn("[SkillRoutine] Promotion failed for candidate {} ('{}'): {}", + candidate.getId(), candidate.getSignature(), e.getMessage()); + } + } + return promoted; + } + + /** + * Synthesize and persist the skill for one candidate. + * + *

Exposed so an operator can promote a candidate that has not yet met + * the recurrence gates — the gates bound what the unattended pass does on + * its own, not what a person may decide to do. + * + * @return {@code true} when a new skill was created + */ + public boolean promoteCandidate(SkillRoutineCandidateEntity candidate) { + return promote(candidate); + } + + private boolean promote(SkillRoutineCandidateEntity candidate) { + List conversationIds = parseSamples(candidate.getSampleConversations()); + String evidence = buildEvidence(conversationIds); + if (evidence.isBlank()) { + log.debug("[SkillRoutine] Candidate {} has no readable transcripts, skipping", + candidate.getId()); + return false; + } + + String llmResponse; + try { + String systemPrompt = PromptLoader.loadPrompt("skill/routine-system"); + String userPrompt = PromptLoader.loadPrompt("skill/routine-user") + .replace("{occurrences}", String.valueOf(candidate.getOccurrenceCount())) + .replace("{days}", String.valueOf(candidate.getDistinctDayCount())) + .replace("{request}", candidate.getRepresentativeText() == null + ? candidate.getSignature() : candidate.getRepresentativeText()) + .replace("{evidence}", evidence); + ChatModel chatModel = buildChatModel(); + ChatResponse response = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt)))); + llmResponse = response == null || response.getResult() == null + || response.getResult().getOutput() == null + ? null : response.getResult().getOutput().getText(); + } catch (Exception e) { + log.warn("[SkillRoutine] Synthesis LLM call failed for candidate {}: {}", + candidate.getId(), e.getMessage()); + return false; + } + + JsonNode plan = parseJson(llmResponse); + if (plan == null) { + return false; + } + String name = plan.path("name").asText("").strip().toLowerCase(); + String content = plan.path("content").asText(null); + if (name.isBlank() || content == null || content.isBlank()) { + log.debug("[SkillRoutine] Candidate {} produced no usable skill", candidate.getId()); + return false; + } + + ToolContext toolContext = buildToolContext(candidate, conversationIds); + String result = skillManageTool.skillManageAs(SkillOrigin.ROUTINE, "create", name, content, + null, null, null, toolContext); + boolean created = result != null && !result.startsWith("Error") + && !result.startsWith("Security scan BLOCKED"); + boolean alreadyCovered = result != null && result.contains("already exists"); + if (!created && !alreadyCovered) { + log.info("[SkillRoutine] Candidate {} rejected by skill_manage: {}", candidate.getId(), result); + return false; + } + + candidate.setStatus(SkillRoutineCandidateEntity.STATUS_PROMOTED); + candidate.setPromotedSkillName(name); + candidate.setPromotedAt(LocalDateTime.now()); + candidateMapper.updateById(candidate); + log.info("[SkillRoutine] Promoted routine '{}' → skill '{}' for agent={} ({} occurrences over {} days)", + candidate.getSignature(), name, candidate.getAgentId(), + candidate.getOccurrenceCount(), candidate.getDistinctDayCount()); + return created; + } + + /** + * Stamp the tool call with the owning agent and the most recent member + * conversation, so the created skill is attributed and auto-bound. + */ + private ToolContext buildToolContext(SkillRoutineCandidateEntity candidate, List conversationIds) { + String sourceConversation = conversationIds.isEmpty() ? null : conversationIds.get(0); + ChatOrigin origin = new ChatOrigin(candidate.getAgentId(), sourceConversation, "", + candidate.getWorkspaceId(), null, null, null, false, null, null, null, null, null); + return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin)); + } + + /** + * Render the sample conversations as labelled transcripts. Each is capped + * so a handful of long sessions cannot blow the synthesis context. + */ + private String buildEvidence(List conversationIds) { + StringBuilder sb = new StringBuilder(); + int index = 0; + for (String conversationId : conversationIds) { + List messages; + try { + messages = conversationService.listMessages(conversationId); + } catch (Exception e) { + continue; + } + if (messages == null || messages.isEmpty()) { + continue; + } + int limit = Math.max(2, properties.getTranscriptMessagesPerSample()); + List window = messages.size() > limit + ? messages.subList(0, limit) : messages; + index++; + sb.append("### Occurrence ").append(index).append("\n"); + for (MessageEntity m : window) { + String label = switch (m.getRole() == null ? "" : m.getRole()) { + case "user" -> "User"; + case "assistant" -> "Assistant"; + case "tool" -> "Tool[" + (m.getToolName() == null ? "unknown" : m.getToolName()) + "]"; + default -> null; + }; + if (label == null || m.getContent() == null || m.getContent().isBlank()) { + continue; + } + sb.append(label).append(": ") + .append(SecretRedactor.redact( + truncate(m.getContent(), properties.getTranscriptTruncateChars()))) + .append("\n"); + } + sb.append("\n"); + } + return sb.toString().strip(); + } + + private List parseSamples(String json) { + List out = new ArrayList<>(); + if (json == null || json.isBlank()) { + return out; + } + try { + JsonNode node = objectMapper.readTree(json); + if (node.isArray()) { + for (JsonNode n : node) { + String v = n.asText(""); + if (!v.isBlank()) { + out.add(v); + } + } + } + } catch (Exception e) { + log.debug("[SkillRoutine] Sample list parse failed: {}", e.getMessage()); + } + return out; + } + + private JsonNode parseJson(String response) { + if (response == null || response.isBlank()) { + return null; + } + String cleaned = response.strip(); + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); + } + try { + JsonNode node = objectMapper.readTree(cleaned.strip()); + return node != null && node.isObject() ? node : null; + } catch (Exception e) { + log.debug("[SkillRoutine] Synthesis JSON parse failed: {}", e.getMessage()); + return null; + } + } + + private ChatModel buildChatModel() { + ModelConfigEntity model = null; + if (properties.getModelId() != null && !properties.getModelId().isBlank()) { + try { + model = modelConfigService.getModel(Long.parseLong(properties.getModelId())); + } catch (Exception e) { + log.warn("[SkillRoutine] Invalid modelId '{}', falling back to default", + properties.getModelId()); + } + } + if (model == null) { + model = modelConfigService.getDefaultModel(); + } + return agentGraphBuilder.buildRuntimeChatModel(model); + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return ""; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineProperties.java new file mode 100644 index 00000000..590666a3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineProperties.java @@ -0,0 +1,64 @@ +package vip.mate.skill.routine; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for routine mining — the cross-session pass that detects + * requests the user makes habitually and promotes them into skills. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.skill.routine") +public class SkillRoutineProperties { + + /** Master switch. When {@code false} neither mining nor promotion runs. */ + private boolean enabled = true; + + /** How far back the mining pass looks, in days. */ + private int lookbackDays = 30; + + /** + * Shingle-similarity threshold above which two conversation openers are + * considered the same request. Tuned toward precision: a false merge + * produces a skill describing a routine the user does not actually have, + * which is worse than missing one and catching it on the next sweep. + */ + private double similarityThreshold = 0.62; + + /** + * Conversations a cluster needs before promotion. Two is coincidence. + */ + private int minOccurrences = 3; + + /** + * Distinct calendar days a cluster must span before promotion. Guards + * against a single afternoon of retries reading as a daily habit. + */ + private int minDistinctDays = 3; + + /** Shortest opener worth clustering; below this the text carries no intent. */ + private int minOpenerChars = 8; + + /** Longest opener prefix fed to the shingler. */ + private int maxOpenerChars = 400; + + /** Conversation ids retained per candidate as promotion evidence. */ + private int maxSamplesPerCandidate = 8; + + /** Candidates promoted in a single sweep, bounding LLM cost per run. */ + private int maxPromotionsPerRun = 2; + + /** Conversations scanned per sweep, bounding memory and query cost. */ + private int maxConversationsPerRun = 1000; + + /** Messages of each sample conversation shown to the synthesizer. */ + private int transcriptMessagesPerSample = 12; + + /** Per-message truncation when building the synthesis transcript. */ + private int transcriptTruncateChars = 800; + + /** Synthesis model ID ({@code null} = follow the system default model). */ + private String modelId; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineService.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineService.java new file mode 100644 index 00000000..76c15856 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/SkillRoutineService.java @@ -0,0 +1,142 @@ +package vip.mate.skill.routine; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.skill.routine.model.SkillRoutineCandidateEntity; +import vip.mate.skill.routine.repository.SkillRoutineCandidateMapper; + +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Admin-facing reads and decisions over mined routine candidates. + * + *

Separate from {@link SkillRoutineMiner} and {@link SkillRoutinePromoter} + * because those are unattended batch passes, while everything here is a person + * looking at what the system inferred about their habits and accepting or + * rejecting it. That review matters: a routine promoted from a misread pattern + * becomes a skill the agent consults on every similar request, so the operator + * needs to see candidates before they qualify, not only after. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillRoutineService { + + private final SkillRoutineCandidateMapper candidateMapper; + private final SkillRoutinePromoter promoter; + private final SkillRoutineProperties properties; + + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + /** + * Candidates for the admin list, newest activity first. + * + * @param status optional filter — {@code observing} / {@code promoted} / + * {@code dismissed}; {@code null} or blank returns all + * @param limit maximum rows + */ + public List> list(String status, int limit) { + LambdaQueryWrapper q = + new LambdaQueryWrapper() + .orderByDesc(SkillRoutineCandidateEntity::getLastSeenAt) + .last("LIMIT " + Math.max(1, Math.min(limit, 200))); + if (status != null && !status.isBlank()) { + q.eq(SkillRoutineCandidateEntity::getStatus, status.strip().toLowerCase()); + } + List> out = new ArrayList<>(); + for (SkillRoutineCandidateEntity row : candidateMapper.selectList(q)) { + out.add(toView(row)); + } + return out; + } + + /** Promotion thresholds, so the UI can show how far a candidate has to go. */ + public Map gates() { + Map gates = new LinkedHashMap<>(); + gates.put("minOccurrences", properties.getMinOccurrences()); + gates.put("minDistinctDays", properties.getMinDistinctDays()); + gates.put("enabled", properties.isEnabled()); + return gates; + } + + /** + * Reject a candidate. Mining skips dismissed signatures on every later + * sweep, so this is permanent until the operator reopens it — without that + * the next nightly pass would simply re-detect the same pattern. + */ + public Map dismiss(Long id) { + SkillRoutineCandidateEntity row = require(id); + row.setStatus(SkillRoutineCandidateEntity.STATUS_DISMISSED); + candidateMapper.updateById(row); + log.info("[SkillRoutine] Candidate {} ('{}') dismissed by operator", id, row.getSignature()); + return toView(row); + } + + /** Put a dismissed candidate back under observation. */ + public Map reopen(Long id) { + SkillRoutineCandidateEntity row = require(id); + row.setStatus(SkillRoutineCandidateEntity.STATUS_OBSERVING); + candidateMapper.updateById(row); + log.info("[SkillRoutine] Candidate {} ('{}') reopened by operator", id, row.getSignature()); + return toView(row); + } + + /** + * Promote a candidate now, bypassing the recurrence gates. + * + *

The gates exist to keep the unattended pass from acting on thin + * evidence; an operator looking at the candidate has better judgement than + * the thresholds, so an explicit request is allowed through. + */ + public Map promoteNow(Long id) { + SkillRoutineCandidateEntity row = require(id); + if (SkillRoutineCandidateEntity.STATUS_PROMOTED.equals(row.getStatus())) { + throw new IllegalStateException("Routine already promoted to skill '" + + row.getPromotedSkillName() + "'"); + } + boolean ok = promoter.promoteCandidate(row); + Map view = toView(require(id)); + view.put("promoted", ok); + return view; + } + + private SkillRoutineCandidateEntity require(Long id) { + SkillRoutineCandidateEntity row = candidateMapper.selectById(id); + if (row == null) { + throw new IllegalArgumentException("Routine candidate " + id + " not found"); + } + return row; + } + + private Map toView(SkillRoutineCandidateEntity row) { + Map m = new LinkedHashMap<>(); + // Snowflake ids as strings: 19 digits exceed JS Number precision. + m.put("id", String.valueOf(row.getId())); + m.put("agentId", row.getAgentId() == null ? null : String.valueOf(row.getAgentId())); + m.put("signature", row.getSignature()); + m.put("representativeText", row.getRepresentativeText()); + m.put("occurrenceCount", row.getOccurrenceCount()); + m.put("distinctDayCount", row.getDistinctDayCount()); + m.put("status", row.getStatus()); + m.put("promotedSkillName", row.getPromotedSkillName()); + m.put("firstSeenAt", row.getFirstSeenAt() == null ? null : row.getFirstSeenAt().format(FMT)); + m.put("lastSeenAt", row.getLastSeenAt() == null ? null : row.getLastSeenAt().format(FMT)); + m.put("qualified", meetsGates(row)); + return m; + } + + private boolean meetsGates(SkillRoutineCandidateEntity row) { + int occurrences = row.getOccurrenceCount() == null ? 0 : row.getOccurrenceCount(); + int days = row.getDistinctDayCount() == null ? 0 : row.getDistinctDayCount(); + return occurrences >= properties.getMinOccurrences() + && days >= properties.getMinDistinctDays(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/model/SkillRoutineCandidateEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/model/SkillRoutineCandidateEntity.java new file mode 100644 index 00000000..c1edc535 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/model/SkillRoutineCandidateEntity.java @@ -0,0 +1,91 @@ +package vip.mate.skill.routine.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * A cluster of conversations that opened with substantially the same user + * request — the accumulating evidence that some request is a routine rather + * than a one-off. + * + *

Exists because recurrence is invisible from inside a single conversation. + * The post-turn reflection reviewer sees one window and correctly declines to + * write a skill for what looks like a one-off task; only a cross-session count + * can distinguish "the user asked this once" from "the user asks this every + * Monday". This row carries that count. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_skill_routine_candidate") +public class SkillRoutineCandidateEntity { + + /** Still gathering evidence; below the promotion gate. */ + public static final String STATUS_OBSERVING = "observing"; + /** A skill has been synthesized from this cluster. */ + public static final String STATUS_PROMOTED = "promoted"; + /** Operator rejected this cluster; never promote it. */ + public static final String STATUS_DISMISSED = "dismissed"; + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Agent the routine belongs to — routines are per-agent, not global. */ + private Long agentId; + + private Long workspaceId; + + /** Normalized representative text; the human-readable routine identity. */ + private String signature; + + /** Stable hash of {@link #signature}, used as the upsert key. */ + private String signatureHash; + + /** Verbatim opener of the most recent member conversation. */ + @TableField(value = "representative_text", updateStrategy = FieldStrategy.ALWAYS) + private String representativeText; + + /** JSON array of member conversation ids, capped by the miner. */ + @TableField(value = "sample_conversations", updateStrategy = FieldStrategy.ALWAYS) + private String sampleConversations; + + /** Conversations observed in this cluster. */ + private Integer occurrenceCount; + + /** + * Distinct calendar days the cluster was seen on. Separate from + * {@link #occurrenceCount} because five conversations in one afternoon is + * one person retrying, whereas five conversations across five days is a + * habit. Promotion requires both. + */ + private Integer distinctDayCount; + + private LocalDateTime firstSeenAt; + + private LocalDateTime lastSeenAt; + + /** {@code observing} | {@code promoted} | {@code dismissed}. */ + private String status; + + /** Name of the skill synthesized from this cluster, once promoted. */ + @TableField(value = "promoted_skill_name", updateStrategy = FieldStrategy.ALWAYS) + private String promotedSkillName; + + @TableField(value = "promoted_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime promotedAt; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/routine/repository/SkillRoutineCandidateMapper.java b/mateclaw-server/src/main/java/vip/mate/skill/routine/repository/SkillRoutineCandidateMapper.java new file mode 100644 index 00000000..4b1c56df --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/routine/repository/SkillRoutineCandidateMapper.java @@ -0,0 +1,14 @@ +package vip.mate.skill.routine.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.skill.routine.model.SkillRoutineCandidateEntity; + +/** + * Data access for recurring-request candidates. + * + * @author MateClaw Team + */ +@Mapper +public interface SkillRoutineCandidateMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index 38e1e3a1..877e7720 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -6,10 +6,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.agent.context.ChatOrigin; +import vip.mate.skill.event.SkillAuthoredEvent; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillOrigin; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillSecurityService; import vip.mate.skill.runtime.SkillValidationResult; @@ -44,6 +47,7 @@ public class SkillManageTool { private final SkillSecurityService securityService; private final SkillWorkspaceManager workspaceManager; private final SkillRuntimeService runtimeService; + private final ApplicationEventPublisher eventPublisher; /** Skill 名称格式:小写字母/数字/连字符/下划线/点,首字符必须是字母或数字 */ private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9._-]{0,63}$"); @@ -140,6 +144,27 @@ public class SkillManageTool { // skill with the agent's owning workspace. @Nullable ToolContext toolContext ) { + // A tool call is by definition a live conversation turn, so anything + // arriving here was asked for by a person. Autonomous callers use + // skillManageAs() and declare their own origin. + return skillManageAs(SkillOrigin.USER, action, name, content, oldText, newText, filePath, toolContext); + } + + /** + * Same pipeline as {@link #skill_manage}, with the authorship stamp made + * explicit for callers that are not a user-facing turn — the reflection + * reviewer and the routine promoter. + * + *

Not exposed to the model: origin is a trust boundary, and a value the + * model could set would be worth nothing. Routing autonomous writes through + * the same method keeps them subject to the identical security scan, name + * validation, builtin guard, and workspace export. + * + * @param skillOrigin authorship to stamp on a newly created skill + */ + public String skillManageAs(SkillOrigin skillOrigin, String action, String name, String content, + String oldText, String newText, String filePath, + @Nullable ToolContext toolContext) { if (action == null || action.isBlank()) { return "Error: action is required (create | edit | patch | delete)"; } @@ -158,7 +183,8 @@ public class SkillManageTool { String sourceConversationId = origin.conversationId(); return switch (action.strip().toLowerCase()) { - case "create" -> doCreate(normalizedName, content, workspaceId, sourceConversationId); + case "create" -> doCreate(normalizedName, content, workspaceId, sourceConversationId, + origin.agentId(), skillOrigin); case "edit" -> doEdit(normalizedName, content); case "patch" -> doPatch(normalizedName, oldText, newText); case "write_file" -> doWriteFile(normalizedName, filePath, content); @@ -169,7 +195,8 @@ public class SkillManageTool { // ==================== Create ==================== - private String doCreate(String name, String content, Long workspaceId, String sourceConversationId) { + private String doCreate(String name, String content, Long workspaceId, String sourceConversationId, + Long agentId, SkillOrigin skillOrigin) { if (content == null || content.isBlank()) { return "Error: content is required for create action. Provide full SKILL.md content."; } @@ -205,6 +232,10 @@ public class SkillManageTool { if (sourceConversationId != null && !sourceConversationId.isBlank()) { skill.setSourceConversationId(sourceConversationId); } + // Authorship decides whether autonomous curation may later age or + // rewrite this skill. Stamped here because this is the only point + // that still knows whether a user was present. + skill.setOrigin((skillOrigin == null ? SkillOrigin.USER : skillOrigin).code()); skillService.createSkill(skill); @@ -215,6 +246,17 @@ public class SkillManageTool { log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); } + // Announce authorship so the agent layer can make the skill + // reachable from the authoring agent's own catalog. Best-effort: + // the skill is already persisted, so a listener failure must not + // turn a successful create into an error for the model. + try { + eventPublisher.publishEvent(new SkillAuthoredEvent( + skill.getId(), name, agentId, sourceConversationId, skill.getWorkspaceId())); + } catch (Exception e) { + log.warn("[SkillManage] SkillAuthoredEvent publish failed for '{}': {}", name, e.getMessage()); + } + log.info("[SkillManage] Agent created skill: name={}, contentLen={}", name, content.length()); return "Skill '" + name + "' created successfully (security scan: PASSED). " + "It is now available in your skill list for future conversations."; diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index ed195b34..10be017a 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -205,6 +205,68 @@ mateclaw: # server's local date (a UTC container groups by UTC days). date-folders: ${MATECLAW_CHAT_UPLOAD_DATE_FOLDERS:true} skill: + reflection: + # Out-of-band skill reflection: after a conversation reaches the cadence + # below, an async reviewer reads the recent window and creates or + # improves skills through the same skill_manage pipeline the agent uses. + # Runs off the request thread, so it never consumes the live turn's + # context window. + enabled: ${MATECLAW_SKILL_REFLECTION_ENABLED:true} + # Review once this many new messages have accumulated since the last + # attempt. 0 disables the cadence gate entirely. + review-turn-interval: ${MATECLAW_SKILL_REFLECTION_TURN_INTERVAL:8} + # Substance floor — a window with fewer assistant turns than this rarely + # contains a reusable workflow, so the review is skipped before any LLM + # call is made. + min-assistant-turns: ${MATECLAW_SKILL_REFLECTION_MIN_ASSISTANT_TURNS:2} + # Most recent messages handed to the reviewer. + max-messages: ${MATECLAW_SKILL_REFLECTION_MAX_MESSAGES:24} + # Per-conversation cooldown between reviews, in minutes. Applies on top + # of the cadence gate, so a busy conversation reviews at most this often. + cooldown-minutes: ${MATECLAW_SKILL_REFLECTION_COOLDOWN_MINUTES:30} + # Hard cap on create/edit/patch actions applied by a single review. + max-actions-per-run: ${MATECLAW_SKILL_REFLECTION_MAX_ACTIONS:3} + # Character budget for the existing-skill catalog shown to the reviewer. + # Skill bodies are truncated to fit; the reviewer is told not to target + # truncated text with a patch. + catalog-char-budget: ${MATECLAW_SKILL_REFLECTION_CATALOG_BUDGET:8000} + # Reviewer model id. Empty follows the system default model. + model-id: ${MATECLAW_SKILL_REFLECTION_MODEL_ID:} + routine: + # Routine mining: a nightly cross-session pass that clusters the opening + # request of recent conversations and promotes the ones the user makes + # habitually into class-level skills. Recurrence is invisible to the + # per-conversation reflection reviewer above — inside one window a weekly + # request is indistinguishable from a one-off — so this pass supplies the + # cross-session evidence that reviewer structurally cannot see. + enabled: ${MATECLAW_SKILL_ROUTINE_ENABLED:true} + cron: ${MATECLAW_SKILL_ROUTINE_CRON:0 0 3 * * *} + # How far back each sweep looks. A routine the user stops doing decays + # out of this window on its own. + lookback-days: ${MATECLAW_SKILL_ROUTINE_LOOKBACK_DAYS:30} + # Shingle-similarity above which two openers count as the same request. + # Tuned toward precision — a false merge invents a routine the user does + # not have, which is worse than missing one until the next sweep. + similarity-threshold: ${MATECLAW_SKILL_ROUTINE_SIMILARITY:0.62} + # Promotion gate. Both must hold: occurrences proves repetition, distinct + # days proves habit rather than one afternoon of retries. + min-occurrences: ${MATECLAW_SKILL_ROUTINE_MIN_OCCURRENCES:3} + min-distinct-days: ${MATECLAW_SKILL_ROUTINE_MIN_DISTINCT_DAYS:3} + # Shortest opener worth clustering, and the prefix length fed to the + # shingler. + min-opener-chars: ${MATECLAW_SKILL_ROUTINE_MIN_OPENER_CHARS:8} + max-opener-chars: ${MATECLAW_SKILL_ROUTINE_MAX_OPENER_CHARS:400} + # Conversation ids retained per candidate as promotion evidence. + max-samples-per-candidate: ${MATECLAW_SKILL_ROUTINE_MAX_SAMPLES:8} + # Candidates promoted per sweep, bounding LLM cost per run. + max-promotions-per-run: ${MATECLAW_SKILL_ROUTINE_MAX_PROMOTIONS:2} + # Conversations scanned per sweep, bounding query and memory cost. + max-conversations-per-run: ${MATECLAW_SKILL_ROUTINE_MAX_CONVERSATIONS:1000} + # Transcript shaping for the synthesis prompt. + transcript-messages-per-sample: ${MATECLAW_SKILL_ROUTINE_TRANSCRIPT_MESSAGES:12} + transcript-truncate-chars: ${MATECLAW_SKILL_ROUTINE_TRANSCRIPT_TRUNCATE:800} + # Synthesis model id. Empty follows the system default model. + model-id: ${MATECLAW_SKILL_ROUTINE_MODEL_ID:} upload: # Size caps for skill bundle ZIPs (upload endpoint and marketplace # install). The archive is buffered in memory during extraction, so @@ -231,10 +293,19 @@ mateclaw: cron: "0 0 2 * * *" # daily 02:00 — staggered away from wiki / backup jobs stale-after-days: 30 archive-after-days: 90 + # AGENT_CREATED scopes the sweep to skills written autonomously + # (origin=agent|routine). Skills a user asked for in a conversation are + # stamped origin=user and are never aged out under this scope. scope: AGENT_CREATED # AGENT_CREATED | ALL_DYNAMIC | OFF protect-prefixes: - "sys-" - "ops-" + # Restore point captured before every mutating sweep. The sweep archives + # skills and, with consolidation on, rewrites their bodies — unattended + # and overnight, so a bad pass is usually noticed long after it ran. + # Disabling this makes those changes one-way. + backup-enabled: ${MATECLAW_SKILL_CURATOR_BACKUP_ENABLED:true} + backup-keep: ${MATECLAW_SKILL_CURATOR_BACKUP_KEEP:5} hub: base-url: https://clawhub.ai search-path: /api/v1/search diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V176__skill_routine_candidate.sql b/mateclaw-server/src/main/resources/db/migration/h2/V176__skill_routine_candidate.sql new file mode 100644 index 00000000..76ee6b85 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V176__skill_routine_candidate.sql @@ -0,0 +1,43 @@ +-- V176: Recurring-request candidates for routine mining. +-- A single conversation cannot show that a request is habitual, so the +-- reflection reviewer (which only ever sees one window) correctly treats +-- repeat work as a one-off narrative. This table accumulates the cross-session +-- evidence that reflection structurally cannot see: how many separate +-- conversations opened with substantially the same request, over how many +-- distinct days. Once a cluster clears the recurrence gate it is promoted into +-- a class-level skill. H2 dialect uses CLOB for the sample payload. + +CREATE TABLE IF NOT EXISTS mate_skill_routine_candidate ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + workspace_id BIGINT, + -- Normalized representative text of the cluster; the human-readable + -- identity of the routine ("summarize today's on-call alerts"). + signature VARCHAR(512) NOT NULL, + -- Stable hash of the signature, used as the upsert key. A hash rather + -- than the signature itself so the unique index stays inside index key + -- length limits on every dialect. + signature_hash VARCHAR(64) NOT NULL, + -- Verbatim opener of the most recent member conversation, kept for the + -- synthesis prompt so it works from real phrasing, not the normalized form. + representative_text VARCHAR(2048), + -- JSON array of member conversation ids, capped by the miner. + sample_conversations CLOB, + occurrence_count INT NOT NULL DEFAULT 0, + distinct_day_count INT NOT NULL DEFAULT 0, + first_seen_at TIMESTAMP, + last_seen_at TIMESTAMP, + -- observing = accumulating evidence; promoted = a skill was synthesized; + -- dismissed = operator rejected, never re-promote. + status VARCHAR(16) NOT NULL DEFAULT 'observing', + promoted_skill_name VARCHAR(128), + promoted_at TIMESTAMP, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_routine_agent_signature + ON mate_skill_routine_candidate (agent_id, signature_hash, deleted); +CREATE INDEX IF NOT EXISTS idx_routine_status + ON mate_skill_routine_candidate (status, occurrence_count); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V177__skill_origin_and_snapshot.sql b/mateclaw-server/src/main/resources/db/migration/h2/V177__skill_origin_and_snapshot.sql new file mode 100644 index 00000000..ebecb7ac --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V177__skill_origin_and_snapshot.sql @@ -0,0 +1,39 @@ +-- V177: Curation provenance + pre-sweep snapshots. +-- +-- 1. mate_skill.origin — authorship as a policy flag ('user' | 'agent' | +-- 'routine'). source_conversation_id alone cannot gate curation: a skill +-- the user asked for in a live conversation carries one just like a skill +-- the background reviewer invented, so the curator had no way to tell them +-- apart and aged both on the same clock. +-- +-- Backfill deliberately stamps every existing conversation-sourced skill as +-- 'agent', which reproduces the curator's current candidate set exactly, so +-- upgrading changes no behaviour. Authorship of those rows is genuinely +-- unrecoverable — it is not inferred, it is preserved. Only writes from +-- this version forward carry a true value. +-- +-- 2. mate_skill_snapshot — a restore point captured before each mutating +-- sweep. Consolidation rewrites skill bodies and archival moves them out of +-- the active set; both were previously one-way. + +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS origin VARCHAR(16); + +UPDATE mate_skill SET origin = 'agent' + WHERE origin IS NULL AND source_conversation_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_skill_origin ON mate_skill (origin); + +CREATE TABLE IF NOT EXISTS mate_skill_snapshot ( + id BIGINT NOT NULL PRIMARY KEY, + -- Why the snapshot was taken ('pre-sweep', 'pre-restore', or a manual note). + reason VARCHAR(255), + skill_count INT NOT NULL DEFAULT 0, + -- JSON array of the captured skill rows. + payload CLOB, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_skill_snapshot_created + ON mate_skill_snapshot (create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V176__skill_routine_candidate.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V176__skill_routine_candidate.sql new file mode 100644 index 00000000..9b2cf947 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V176__skill_routine_candidate.sql @@ -0,0 +1,44 @@ +-- V176: Recurring-request candidates for routine mining. +-- A single conversation cannot show that a request is habitual, so the +-- reflection reviewer (which only ever sees one window) correctly treats +-- repeat work as a one-off narrative. This table accumulates the cross-session +-- evidence that reflection structurally cannot see: how many separate +-- conversations opened with substantially the same request, over how many +-- distinct days. Once a cluster clears the recurrence gate it is promoted into +-- a class-level skill. PostgreSQL-compatible dialect: TEXT for the sample +-- payload, TIMESTAMP(3) for wall-clock columns. + +CREATE TABLE IF NOT EXISTS mate_skill_routine_candidate ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + workspace_id BIGINT, + -- Normalized representative text of the cluster; the human-readable + -- identity of the routine ("summarize today's on-call alerts"). + signature VARCHAR(512) NOT NULL, + -- Stable hash of the signature, used as the upsert key. A hash rather + -- than the signature itself so the unique index stays inside index key + -- length limits on every dialect. + signature_hash VARCHAR(64) NOT NULL, + -- Verbatim opener of the most recent member conversation, kept for the + -- synthesis prompt so it works from real phrasing, not the normalized form. + representative_text VARCHAR(2048), + -- JSON array of member conversation ids, capped by the miner. + sample_conversations TEXT, + occurrence_count INT NOT NULL DEFAULT 0, + distinct_day_count INT NOT NULL DEFAULT 0, + first_seen_at TIMESTAMP(3), + last_seen_at TIMESTAMP(3), + -- observing = accumulating evidence; promoted = a skill was synthesized; + -- dismissed = operator rejected, never re-promote. + status VARCHAR(16) NOT NULL DEFAULT 'observing', + promoted_skill_name VARCHAR(128), + promoted_at TIMESTAMP(3), + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_routine_agent_signature + ON mate_skill_routine_candidate (agent_id, signature_hash, deleted); +CREATE INDEX IF NOT EXISTS idx_routine_status + ON mate_skill_routine_candidate (status, occurrence_count); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V177__skill_origin_and_snapshot.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V177__skill_origin_and_snapshot.sql new file mode 100644 index 00000000..aeb2dcf7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V177__skill_origin_and_snapshot.sql @@ -0,0 +1,39 @@ +-- V177: Curation provenance + pre-sweep snapshots. +-- +-- 1. mate_skill.origin — authorship as a policy flag ('user' | 'agent' | +-- 'routine'). source_conversation_id alone cannot gate curation: a skill +-- the user asked for in a live conversation carries one just like a skill +-- the background reviewer invented, so the curator had no way to tell them +-- apart and aged both on the same clock. +-- +-- Backfill deliberately stamps every existing conversation-sourced skill as +-- 'agent', which reproduces the curator's current candidate set exactly, so +-- upgrading changes no behaviour. Authorship of those rows is genuinely +-- unrecoverable — it is not inferred, it is preserved. Only writes from +-- this version forward carry a true value. +-- +-- 2. mate_skill_snapshot — a restore point captured before each mutating +-- sweep. Consolidation rewrites skill bodies and archival moves them out of +-- the active set; both were previously one-way. + +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS origin VARCHAR(16); + +UPDATE mate_skill SET origin = 'agent' + WHERE origin IS NULL AND source_conversation_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_skill_origin ON mate_skill (origin); + +CREATE TABLE IF NOT EXISTS mate_skill_snapshot ( + id BIGINT NOT NULL PRIMARY KEY, + -- Why the snapshot was taken ('pre-sweep', 'pre-restore', or a manual note). + reason VARCHAR(255), + skill_count INT NOT NULL DEFAULT 0, + -- JSON array of the captured skill rows. + payload TEXT, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_skill_snapshot_created + ON mate_skill_snapshot (create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V176__skill_routine_candidate.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V176__skill_routine_candidate.sql new file mode 100644 index 00000000..37fe891b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V176__skill_routine_candidate.sql @@ -0,0 +1,41 @@ +-- V176: Recurring-request candidates for routine mining. +-- A single conversation cannot show that a request is habitual, so the +-- reflection reviewer (which only ever sees one window) correctly treats +-- repeat work as a one-off narrative. This table accumulates the cross-session +-- evidence that reflection structurally cannot see: how many separate +-- conversations opened with substantially the same request, over how many +-- distinct days. Once a cluster clears the recurrence gate it is promoted into +-- a class-level skill. Indexes are declared inline because MySQL does not +-- support CREATE INDEX IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS mate_skill_routine_candidate ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + workspace_id BIGINT, + -- Normalized representative text of the cluster; the human-readable + -- identity of the routine ("summarize today's on-call alerts"). + signature VARCHAR(512) NOT NULL, + -- Stable hash of the signature, used as the upsert key. A hash rather + -- than the signature itself so the unique index stays inside index key + -- length limits on every dialect. + signature_hash VARCHAR(64) NOT NULL, + -- Verbatim opener of the most recent member conversation, kept for the + -- synthesis prompt so it works from real phrasing, not the normalized form. + representative_text VARCHAR(2048), + -- JSON array of member conversation ids, capped by the miner. + sample_conversations MEDIUMTEXT, + occurrence_count INT NOT NULL DEFAULT 0, + distinct_day_count INT NOT NULL DEFAULT 0, + first_seen_at DATETIME, + last_seen_at DATETIME, + -- observing = accumulating evidence; promoted = a skill was synthesized; + -- dismissed = operator rejected, never re-promote. + status VARCHAR(16) NOT NULL DEFAULT 'observing', + promoted_skill_name VARCHAR(128), + promoted_at DATETIME, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_routine_agent_signature (agent_id, signature_hash, deleted), + KEY idx_routine_status (status, occurrence_count) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Recurring user-request clusters awaiting skill promotion'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V177__skill_origin_and_snapshot.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V177__skill_origin_and_snapshot.sql new file mode 100644 index 00000000..0f300984 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V177__skill_origin_and_snapshot.sql @@ -0,0 +1,44 @@ +-- V177: Curation provenance + pre-sweep snapshots. +-- +-- 1. mate_skill.origin — authorship as a policy flag ('user' | 'agent' | +-- 'routine'). source_conversation_id alone cannot gate curation: a skill +-- the user asked for in a live conversation carries one just like a skill +-- the background reviewer invented, so the curator had no way to tell them +-- apart and aged both on the same clock. +-- +-- Backfill deliberately stamps every existing conversation-sourced skill as +-- 'agent', which reproduces the curator's current candidate set exactly, so +-- upgrading changes no behaviour. Authorship of those rows is genuinely +-- unrecoverable — it is not inferred, it is preserved. Only writes from +-- this version forward carry a true value. +-- +-- 2. mate_skill_snapshot — a restore point captured before each mutating +-- sweep. Consolidation rewrites skill bodies and archival moves them out of +-- the active set; both were previously one-way. +-- +-- MySQL lacks `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`; +-- both use INFORMATION_SCHEMA guards with PREPARE/EXECUTE instead. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_skill' AND COLUMN_NAME = 'origin'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_skill ADD COLUMN origin VARCHAR(16) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE mate_skill SET origin = 'agent' + WHERE origin IS NULL AND source_conversation_id IS NOT NULL; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_skill' AND INDEX_NAME = 'idx_skill_origin'); +SET @s := IF(@c = 0, 'CREATE INDEX idx_skill_origin ON mate_skill (origin)', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS mate_skill_snapshot ( + id BIGINT NOT NULL PRIMARY KEY, + -- Why the snapshot was taken ('pre-sweep', 'pre-restore', or a manual note). + reason VARCHAR(255), + skill_count INT NOT NULL DEFAULT 0, + -- JSON array of the captured skill rows. + payload LONGTEXT, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + KEY idx_skill_snapshot_created (create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Restore points captured before mutating curator sweeps'; diff --git a/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt b/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt index d81b4bdc..f74a0a59 100644 --- a/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt +++ b/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt @@ -1,13 +1,48 @@ -You are a skill curator for an autonomous AI agent. After a conversation finishes, you review what happened and decide whether any REUSABLE skill should be created, or an existing skill improved, so the agent gets better over time. +You are a skill curator for an autonomous AI agent. After a conversation finishes, you review what happened and decide how the agent's skill library should change so it handles this class of work better next time. -A skill is a reusable SKILL.md playbook for a CLASS of task — not a log of one conversation. Only act when a durable, repeatable workflow, fix, or technique clearly emerged. +A skill is a reusable SKILL.md playbook for a CLASS of task — not a log of one conversation. -Follow this discipline strictly, in order: -1. PREFER improving an existing skill. If the conversation used or relates to a skill that is now outdated, incomplete, or wrong, patch or edit that skill instead of creating a new one. -2. Only CREATE a new skill when the workflow is genuinely new and not already covered by an existing skill. -3. Do NOT save: transient errors, one-off answers, secrets/credentials, environment-specific values, or anything that will not help a future task. -4. Keep skills general and class-level. Never create a near-duplicate of an existing skill. -5. When in doubt, do nothing. An empty result is the correct and common outcome. +Be ACTIVE. Most substantive sessions produce at least one worthwhile update, usually a small one. A pass that changes nothing when a signal below fired is a missed learning opportunity, not a safe default. + +## Signals — any one of these warrants an action + +- The user corrected your style, tone, format, verbosity, or approach. Frustration is a FIRST-CLASS skill signal: "stop doing X", "too verbose", "don't format it like that", "just give me the answer", "you always do Y". Embed the correction in the skill that governs that kind of task so the next session starts already fixed. +- The user corrected your workflow or the order of steps. Record it as an explicit step or a gotcha. +- A non-trivial technique, fix, workaround, or debugging path emerged that a future session would otherwise have to rediscover. +- A skill that was loaded or consulted this session turned out to be wrong, incomplete, or outdated. Patch it now. +- The user asked for something they have clearly asked for before, and handling it required knowledge that is not yet written down anywhere. + +## Action ladder — pick the EARLIEST rung that fits + +1. PATCH A SKILL THAT WAS IN PLAY. If the conversation loaded or referenced a skill covering this territory, patch that one. It was in play, so it is where the lesson belongs. +2. PATCH AN EXISTING CLASS-LEVEL SKILL. If no skill was in play but an existing one covers the class, extend it — add a step, a gotcha, or broaden its "When to Use". +3. EDIT for a larger rewrite of an existing skill, when a targeted patch cannot express the change. +4. CREATE a new skill only when no existing skill covers the class at all. + +Climbing to rung 4 when rung 1 or 2 would have worked is how a library degenerates into dozens of narrow near-duplicates. Prefer growing an existing skill. + +## Naming (rung 4 only) + +The name must be at the CLASS level: lowercase letters, digits and hyphens, e.g. "spring-boot-scaffold". It must NOT encode a single incident — no ticket numbers, error strings, dates, feature codenames, or "fix-X" / "debug-Y" shapes. If the name you are about to write only makes sense for today's task, that is proof you belong on rung 1, 2, or 3 instead. + +## User preferences belong in skills, not only in memory + +Memory records who the user is. A skill records how to do this class of task for this user. When the user complains about how you handled something, the skill governing that task needs to carry the lesson — otherwise the next session repeats the mistake. + +## Do NOT capture + +These harden into self-imposed constraints that mislead future sessions long after the underlying situation changed: + +- Environment-dependent failures: missing binaries, unset credentials, uninstalled packages, "command not found". The user can fix these; they are not durable rules. +- Negative claims about tools or features ("tool X doesn't work", "cannot do Y"). These become refusals the agent cites against itself for months after the problem is fixed. If a tool failed because of setup state, capture the FIX (the install command, the config key, the env var) instead. +- Transient errors that resolved before the conversation ended. If a retry worked, the lesson is the retry pattern, not the original failure. +- Secrets, credentials, tokens, and environment-specific values such as absolute paths or hostnames. +- One-off task narratives. "Summarize this document" is not a class of work. +- Unresolved failures. If the session ended without a working method — several things were tried, none worked — do NOT write the attempts up as a recommended approach. That presents an untested sequence of failures as validated guidance a future session will trust and repeat. Either output nothing, or capture only a genuinely working alternative you are confident in. + +An empty array is a real option when the session ran smoothly, produced no new technique, and drew no correction. It should not be your default. + +## Output Output ONLY a JSON array — no prose, no markdown code fences. Each element is one action: {"action":"create","name":"","reason":"","content":""} @@ -16,6 +51,6 @@ Output ONLY a JSON array — no prose, no markdown code fences. Each element is Rules for the fields: - For create/edit, "content" MUST be a complete SKILL.md: YAML frontmatter (name, description, version) followed by a markdown body with sections like "## When to Use", "## Steps", "## Gotchas". -- For patch, give "oldText" exactly as it appears in the current skill and the "newText" to replace it with. Use patch for small, targeted fixes. +- For patch, "oldText" must reproduce text from the skill EXACTLY as shown to you, including whitespace. Keep it short and unique — one line, or a few consecutive lines. The skill bodies below may be truncated; never target text near a truncation marker. If the section you need to change is not fully visible, use "edit" instead. - "name" is a slug: lowercase letters, digits, hyphens (e.g. "spring-boot-scaffold"). - If nothing is worth saving, output exactly: [] diff --git a/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt b/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt index 40cea4f7..3b7558c1 100644 --- a/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt +++ b/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt @@ -1,5 +1,6 @@ ## Existing skills -Review these FIRST. Prefer improving one of them over creating a new skill. Avoid duplicates. + +Review these FIRST — the action ladder starts with improving one of them, not creating a new one. Bodies may be cut off at a "[truncated]" marker; treat anything past it as unseen. {skills} @@ -7,4 +8,4 @@ Review these FIRST. Prefer improving one of them over creating a new skill. Avoi {transcript} -Decide what — if anything — to create or improve, following the discipline rules. Output ONLY the JSON array. +Work the ladder from rung 1 and stop at the first rung that fits. Output ONLY the JSON array. diff --git a/mateclaw-server/src/main/resources/prompts/skill/routine-system.txt b/mateclaw-server/src/main/resources/prompts/skill/routine-system.txt new file mode 100644 index 00000000..5c6ca1f4 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/routine-system.txt @@ -0,0 +1,34 @@ +You write a reusable SKILL.md for a request the user makes habitually. + +You are not reviewing one conversation. You are shown several separate conversations that all served substantially the same request, along with how many times it recurred and over how many distinct days. That repetition is the evidence: this is an established routine, not a one-off task, and it is worth writing down. + +Your job is to describe the routine at the CLASS level — the shape all the occurrences share — so a future session can execute it immediately instead of rediscovering it. + +## How to read the evidence + +- Look for what is CONSTANT across occurrences: the goal, the tools used, the order of steps, the output format the user accepted, the constraints they restated. +- Treat what VARIES as parameters, not as content. Dates, ticket ids, filenames, target names and numbers change every run — describe them as inputs the skill takes, never hardcode one run's values. +- Where occurrences differ in approach, prefer the one the user reacted best to. If the user corrected the assistant in a later occurrence, the correction is the rule. +- If the occurrences reveal a step that consistently caused trouble, write it under "## Gotchas". + +## Quality bar + +- Write only what the transcripts actually show. Never invent a command, flag, path, API, or tool name you did not see. If a detail is unclear across occurrences, describe the intent and leave the specific out rather than guessing. +- Do NOT bake in secrets, tokens, credentials, absolute paths, hostnames, or any single run's concrete values. +- Do NOT narrate the occurrences ("in the first conversation the user asked..."). The skill is a playbook, not a report. +- Keep it tight and scannable — around 80 lines is right for most routines. + +## Naming + +The name must be a class-level slug: lowercase letters, digits and hyphens, describing the recurring job. It must NOT encode any single run — no dates, ticket numbers, or one-off values. +Good: "daily-oncall-digest", "weekly-revenue-report", "pr-review-checklist" +Bad: "summarize-2026-08-04", "fix-issue-4213", "report" + +## Output + +Output ONLY a JSON object — no prose, no markdown code fences: +{"name":"","content":""} + +"content" MUST be a complete SKILL.md: YAML frontmatter (name, description, version) followed by a markdown body with "## When to Use" (the trigger phrasings the user actually uses), "## Inputs" (what varies per run), "## Steps" (the constant procedure), and "## Gotchas" where the evidence supports one. + +If the occurrences are too dissimilar to describe one coherent routine, output exactly: {} diff --git a/mateclaw-server/src/main/resources/prompts/skill/routine-user.txt b/mateclaw-server/src/main/resources/prompts/skill/routine-user.txt new file mode 100644 index 00000000..3fe5da35 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/routine-user.txt @@ -0,0 +1,14 @@ +## Recurrence evidence + +This request was made in {occurrences} separate conversations, spread over {days} distinct days. + +Most recent phrasing of the request: +{request} + +## The occurrences + +Each block below is the opening stretch of one conversation that served this request. Transcripts are truncated; treat anything after a "[truncated]" marker as unseen. + +{evidence} + +Write the skill that captures what these occurrences have in common. Output ONLY the JSON object. diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentSkillAutoBindListenerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentSkillAutoBindListenerTest.java new file mode 100644 index 00000000..e760e698 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentSkillAutoBindListenerTest.java @@ -0,0 +1,106 @@ +package vip.mate.agent.binding; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.skill.event.SkillAuthoredEvent; + +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for the auto-bind listener that makes a self-authored skill reachable + * from the authoring agent's catalog. + * + *

The behaviour under test is entirely about which of the three binding + * states justify writing a row — binding in the wrong state silently revokes + * skills the agent already had, or overrides an explicit operator decision. + */ +class AgentSkillAutoBindListenerTest { + + private AgentBindingService bindingService; + private AgentSkillAutoBindListener listener; + + @BeforeEach + void setUp() { + bindingService = mock(AgentBindingService.class); + listener = new AgentSkillAutoBindListener(bindingService); + } + + private SkillAuthoredEvent event() { + return new SkillAuthoredEvent(99L, "spring-scaffold", 1L, "conv-1", 1L); + } + + @Test + @DisplayName("explicit allowlist → the new skill is bound so the agent can see it") + void bindsWhenAgentUsesAllowlist() { + when(bindingService.getBoundSkillIds(1L)).thenReturn(Set.of(7L, 8L)); + + listener.onSkillAuthored(event()); + + verify(bindingService, times(1)).bindSkill(1L, 99L); + } + + @Test + @DisplayName("no bindings (inherits every skill) → no row written") + void skipsWhenAgentInheritsGlobalDefault() { + // null means "no agent-level restriction". Writing a row here would + // flip the agent into allowlist mode holding exactly this one skill, + // revoking everything else it could previously reach. + when(bindingService.getBoundSkillIds(1L)).thenReturn(null); + + listener.onSkillAuthored(event()); + + verify(bindingService, never()).bindSkill(anyLong(), anyLong()); + } + + @Test + @DisplayName("explicitly scoped to zero skills → operator intent is respected") + void skipsWhenAgentScopedToNoSkills() { + when(bindingService.getBoundSkillIds(1L)).thenReturn(Set.of()); + + listener.onSkillAuthored(event()); + + verify(bindingService, never()).bindSkill(anyLong(), anyLong()); + } + + @Test + @DisplayName("skill already bound → no duplicate write") + void skipsWhenAlreadyBound() { + when(bindingService.getBoundSkillIds(1L)).thenReturn(Set.of(7L, 99L)); + + listener.onSkillAuthored(event()); + + verify(bindingService, never()).bindSkill(anyLong(), anyLong()); + } + + @Test + @DisplayName("no agent origin → nothing to bind to") + void skipsWhenAgentIdMissing() { + listener.onSkillAuthored(new SkillAuthoredEvent(99L, "s", null, "conv-1", 1L)); + + verify(bindingService, never()).getBoundSkillIds(any()); + verify(bindingService, never()).bindSkill(anyLong(), anyLong()); + } + + @Test + @DisplayName("bind failure is swallowed — the skill itself is already persisted") + void swallowsBindFailure() { + when(bindingService.getBoundSkillIds(1L)).thenReturn(Set.of(7L)); + when(bindingService.bindSkill(eq(1L), eq(99L))) + .thenThrow(new IllegalStateException("cross-workspace binding")); + + listener.onSkillAuthored(event()); + + verify(bindingService, times(1)).bindSkill(1L, 99L); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/common/text/SecretRedactorTest.java b/mateclaw-server/src/test/java/vip/mate/common/text/SecretRedactorTest.java new file mode 100644 index 00000000..eb240217 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/text/SecretRedactorTest.java @@ -0,0 +1,73 @@ +package vip.mate.common.text; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for credential masking applied to text that routine mining copies into + * a new table, an admin screen, and a model prompt. + */ +class SecretRedactorTest { + + @Test + @DisplayName("null and empty input pass through") + void handlesEmpty() { + assertEquals(null, SecretRedactor.redact(null)); + assertEquals("", SecretRedactor.redact("")); + } + + @Test + @DisplayName("OpenAI-style keys are masked, including project keys") + void masksOpenAiKeys() { + String out = SecretRedactor.redact("use sk-proj-abcdef1234567890abcdef1234567890 now"); + assertFalse(out.contains("abcdef1234567890"), out); + assertTrue(out.contains(SecretRedactor.MASK), out); + assertTrue(out.startsWith("use ") && out.endsWith(" now"), out); + } + + @Test + @DisplayName("an assignment keeps the field name and masks only the value") + void masksAssignmentValueOnly() { + String out = SecretRedactor.redact("api_key = \"sk-proj-abcdef1234567890abcdef\""); + assertTrue(out.contains("api_key"), "the field name is what makes the text readable: " + out); + assertFalse(out.contains("abcdef"), out); + } + + @Test + @DisplayName("bearer headers, GitHub, Slack, Google and AWS keys are masked") + void masksCommonProviderShapes() { + for (String secret : new String[]{ + "Bearer eyJhbGciOiJIUzI1NiJ9.abcdefghijklmnop", + "ghp_abcdefghijklmnopqrstuvwxyz012345", + "xoxb-123456789012-abcdefghijklmno", + "AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ01234", + "AKIAIOSFODNN7EXAMPLE", + }) { + String out = SecretRedactor.redact("prefix " + secret + " suffix"); + assertTrue(out.contains(SecretRedactor.MASK), "not masked: " + secret + " -> " + out); + } + } + + @Test + @DisplayName("ordinary request text is left intact") + void leavesNormalTextAlone() { + String text = "帮我生成今天的运维日报,重点看错误率"; + assertEquals(text, SecretRedactor.redact(text)); + + String english = "generate the weekly oncall digest for the team"; + assertEquals(english, SecretRedactor.redact(english)); + } + + @Test + @DisplayName("words that merely mention a secret are not mangled") + void doesNotOverMatchProse() { + // No assignment and no key shape — masking here would destroy the very + // words that distinguish one routine from another. + String text = "remind me to rotate the password next week"; + assertEquals(text, SecretRedactor.redact(text)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/common/text/ShinglesTest.java b/mateclaw-server/src/test/java/vip/mate/common/text/ShinglesTest.java new file mode 100644 index 00000000..973e62af --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/text/ShinglesTest.java @@ -0,0 +1,76 @@ +package vip.mate.common.text; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the shared shingling util that memory relevance scoring and + * routine recurrence detection both depend on. + */ +class ShinglesTest { + + @Test + @DisplayName("null and empty input yield an empty set") + void handlesEmptyInput() { + assertTrue(Shingles.of(null).isEmpty()); + assertTrue(Shingles.of("").isEmpty()); + } + + @Test + @DisplayName("Latin tokens shorter than two characters are dropped") + void dropsSingleLatinCharacters() { + Set s = Shingles.of("a bc def"); + assertTrue(s.contains("bc")); + assertTrue(s.contains("def")); + assertTrue(!s.contains("a")); + } + + @Test + @DisplayName("CJK runs become character bigrams") + void producesCjkBigrams() { + Set s = Shingles.of("运维日报"); + assertEquals(Set.of("运维", "维日", "日报"), s); + } + + @Test + @DisplayName("an isolated CJK character is kept whole") + void keepsIsolatedCjkCharacter() { + assertTrue(Shingles.of("查 a").contains("查")); + } + + @Test + @DisplayName("mixed-script text yields both token kinds") + void mixesLatinAndCjk() { + Set s = Shingles.of("生成 report"); + assertTrue(s.contains("生成")); + assertTrue(s.contains("report")); + } + + @Test + @DisplayName("jaccard is 1.0 for identical sets and 0.0 when disjoint") + void jaccardBounds() { + Set a = Shingles.of("运维日报"); + assertEquals(1.0, Shingles.jaccard(a, Shingles.of("运维日报")), 1e-9); + assertEquals(0.0, Shingles.jaccard(a, Shingles.of("营收数字")), 1e-9); + } + + @Test + @DisplayName("jaccard is 0.0 when either side is empty") + void jaccardHandlesEmpty() { + assertEquals(0.0, Shingles.jaccard(Shingles.of("abc"), Set.of()), 1e-9); + assertEquals(0.0, Shingles.jaccard(null, Shingles.of("abc")), 1e-9); + } + + @Test + @DisplayName("jaccard is symmetric regardless of argument order") + void jaccardIsSymmetric() { + Set a = Shingles.of("生成今天的运维日报"); + Set b = Shingles.of("生成今天的运维日报,谢谢"); + assertEquals(Shingles.jaccard(a, b), Shingles.jaccard(b, a), 1e-9); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java index 8193139e..3b699ae3 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java @@ -48,6 +48,9 @@ class SkillControllerBundleFilesTest { controller = new SkillController( skillService, runtimeService, null, workspaceManager, null, fileSyncer, null, null, null, null, null, null, null, null, null, null, null, + /* skillSnapshotService */ null, + /* skillRoutineService */ null, + /* skillRoutineMiner */ null, fileService); } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java index dbe8ed13..53f7d345 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java @@ -58,7 +58,10 @@ class SkillControllerLifecycleTest { controller = new SkillController( skillService, null, null, null, null, null, null, null, null, null, null, agentBindingService, null, null, - skillLifecycleService, skillCuratorJob, skillCuratorReportStore, null); + skillLifecycleService, skillCuratorJob, skillCuratorReportStore, + /* skillSnapshotService */ null, + /* skillRoutineService */ null, + /* skillRoutineMiner */ null, null); } private SkillEntity skill(String state, boolean builtin) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java index 489e6069..2b239473 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java @@ -56,6 +56,9 @@ class SkillControllerListEnabledTest { /* skillLifecycleService */ null, /* skillCuratorJob */ null, /* skillCuratorReportStore */ null, + /* skillSnapshotService */ null, + /* skillRoutineService */ null, + /* skillRoutineMiner */ null, /* skillFileService */ null); // listSkills() supplies realSkillNames() for shadow base — default // to empty so each test can override. diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java index e2683ea9..65624257 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java @@ -25,8 +25,8 @@ import static org.mockito.Mockito.when; class SkillControllerVirtualGuardTest { private final SkillController controller = new SkillController( - null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null); @Test @DisplayName("update on a virtual MCP skill id is rejected before hitting the service") @@ -65,7 +65,7 @@ class SkillControllerVirtualGuardTest { McpSkillBridge bridge = mock(McpSkillBridge.class); SkillController c = new SkillController( null, null, null, null, null, null, null, null, null, null, null, null, - bridge, null, null, null, null, null); + bridge, null, null, null, null, null, null, null, null); long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; SkillEntity toggled = new SkillEntity(); toggled.setName("github"); @@ -97,8 +97,8 @@ class SkillControllerVirtualGuardTest { // not the guard. SkillController real = new SkillController( mock(vip.mate.skill.service.SkillService.class), - null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null); long snowflakeId = 1_900_000_001_000_000_902L; // updateSkill on a mocked SkillService returns null without throwing, // which is fine — we just need to confirm the guard didn't fire. diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java index bcec7f67..f0be99b4 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java @@ -12,6 +12,7 @@ import org.springframework.ai.chat.prompt.Prompt; import vip.mate.agent.AgentGraphBuilder; import vip.mate.llm.service.ModelConfigService; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillOrigin; import vip.mate.skill.service.SkillService; import vip.mate.tool.builtin.SkillManageTool; @@ -104,14 +105,14 @@ class SkillConsolidationServiceTest { stubLlm("[{\"umbrella_name\":\"spring-rest\",\"umbrella_content\":\"---\\nname: spring-rest\\n---\\n# X\"," + "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"dupes\"}]"); when(skillService.findByName("spring-rest")).thenReturn(null); - when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + when(skillManageTool.skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any())) .thenReturn("Skill 'spring-rest' created successfully (security scan: PASSED)."); SkillCuratorReport.Builder report = SkillCuratorReport.builder(); service.consolidate(candidates(4), LocalDateTime.now(), false, report); verify(skillManageTool, times(1)) - .skill_manage(eq("create"), eq("spring-rest"), any(), any(), any(), any(), any()); + .skillManageAs(eq(SkillOrigin.AGENT), eq("create"), eq("spring-rest"), any(), any(), any(), any(), any()); verify(lifecycleService, times(1)) .applyManual(argSkill("spring-rest-1"), eq(LifecycleTransition.TO_ARCHIVED), any(), any()); verify(lifecycleService, times(1)) @@ -132,7 +133,7 @@ class SkillConsolidationServiceTest { SkillCuratorReport.Builder report = SkillCuratorReport.builder(); service.consolidate(candidates(4), LocalDateTime.now(), true, report); - verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); verify(lifecycleService, never()).applyManual(any(), any(), any(), any()); List rows = report.build().getConsolidations(); assertEquals(1, rows.size()); @@ -150,7 +151,7 @@ class SkillConsolidationServiceTest { service.consolidate(candidates(4), LocalDateTime.now(), false, report); // Only spring-rest-1 is in scope → 1 absorbed for a NEW umbrella → not a real merge → skipped. - verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); verify(lifecycleService, never()).applyManual(any(), any(), any(), any()); } @@ -163,13 +164,13 @@ class SkillConsolidationServiceTest { String g2 = "{\"umbrella_name\":\"u2\",\"umbrella_content\":\"---\\nname: u2\\n---\\n#\"," + "\"absorb\":[\"spring-rest-3\",\"spring-rest-4\"],\"reason\":\"b\"}"; stubLlm("[" + g1 + "," + g2 + "]"); - when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + when(skillManageTool.skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any())) .thenReturn("created successfully"); SkillCuratorReport.Builder report = SkillCuratorReport.builder(); service.consolidate(candidates(4), LocalDateTime.now(), false, report); - verify(skillManageTool, times(1)).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, times(1)).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } /** Mockito arg matcher for a SkillEntity with the given name. */ diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java index 2e3c449f..78689965 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java @@ -55,6 +55,8 @@ class SkillCuratorJobTest { private CuratorRunNotifier notifier; @Mock private SkillConsolidationService consolidationService; + @Mock + private SkillSnapshotService snapshotService; private SkillLifecycleProperties properties; private SkillCuratorJob job; @@ -72,7 +74,8 @@ class SkillCuratorJobTest { void setUp() { properties = new SkillLifecycleProperties(); job = new SkillCuratorJob(lifecycleService, skillMapper, reportStore, properties, - systemSettingService, agentBindingService, workspaceManager, notifier, consolidationService); + systemSettingService, agentBindingService, workspaceManager, notifier, consolidationService, + snapshotService); } private SkillEntity candidate(long id, String state, LocalDateTime lastActivity) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillSnapshotServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillSnapshotServiceTest.java new file mode 100644 index 00000000..0ac603a8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillSnapshotServiceTest.java @@ -0,0 +1,211 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.skill.lifecycle.model.SkillSnapshotEntity; +import vip.mate.skill.lifecycle.repository.SkillSnapshotMapper; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillOrigin; +import vip.mate.skill.repository.SkillMapper; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for the curator's restore points — the only thing standing between an + * unattended overnight sweep and an unrecoverable skill library. + */ +class SkillSnapshotServiceTest { + + private SkillMapper skillMapper; + private SkillSnapshotMapper snapshotMapper; + private SkillLifecycleProperties properties; + private SkillSnapshotService service; + + @BeforeAll + static void initTableInfo() { + // LambdaQueryWrapper resolves column names through MyBatis Plus's + // per-entity cache, which only Spring normally populates. + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), ""); + TableInfoHelper.initTableInfo(assistant, SkillEntity.class); + TableInfoHelper.initTableInfo(assistant, SkillSnapshotEntity.class); + } + + @BeforeEach + void setUp() { + skillMapper = mock(SkillMapper.class); + snapshotMapper = mock(SkillSnapshotMapper.class); + properties = new SkillLifecycleProperties(); + service = new SkillSnapshotService(skillMapper, snapshotMapper, properties, new ObjectMapper()); + } + + private SkillEntity skill(Long id, String name, String content, String state) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(name); + s.setSkillContent(content); + s.setLifecycleState(state); + s.setOrigin(SkillOrigin.AGENT.code()); + s.setEnabled(true); + s.setPinned(false); + return s; + } + + @Test + @DisplayName("capture serializes every curatable skill") + void captureSerializesSkills() { + when(skillMapper.selectList(any())).thenReturn(List.of( + skill(1L, "a", "# A", "active"), + skill(2L, "b", "# B", "stale"))); + when(snapshotMapper.selectList(any())).thenReturn(List.of()); + + SkillSnapshotEntity snapshot = service.capture("pre-sweep"); + + assertNotNull(snapshot); + assertEquals(2, snapshot.getSkillCount()); + assertEquals("pre-sweep", snapshot.getReason()); + assertTrue(snapshot.getPayload().contains("\"name\":\"a\""), snapshot.getPayload()); + assertTrue(snapshot.getPayload().contains("# B"), snapshot.getPayload()); + verify(snapshotMapper, times(1)).insert(any(SkillSnapshotEntity.class)); + } + + @Test + @DisplayName("capture is skipped when backups are disabled") + void captureRespectsDisabledFlag() { + properties.setBackupEnabled(false); + + assertNull(service.capture("pre-sweep")); + + verify(skillMapper, never()).selectList(any()); + verify(snapshotMapper, never()).insert(any(SkillSnapshotEntity.class)); + } + + @Test + @DisplayName("capture with no skills writes nothing") + void captureWithNoSkills() { + when(skillMapper.selectList(any())).thenReturn(List.of()); + + assertNull(service.capture("pre-sweep")); + + verify(snapshotMapper, never()).insert(any(SkillSnapshotEntity.class)); + } + + @Test + @DisplayName("restore writes the captured content back over the current rows") + void restoreRewritesSkills() { + SkillSnapshotEntity snapshot = new SkillSnapshotEntity(); + snapshot.setId(77L); + snapshot.setPayload("[{\"id\":1,\"name\":\"a\",\"skillContent\":\"# original\"," + + "\"lifecycleState\":\"active\",\"origin\":\"agent\",\"enabled\":true,\"pinned\":false}]"); + when(snapshotMapper.selectById(77L)).thenReturn(snapshot); + when(skillMapper.selectById(1L)).thenReturn(skill(1L, "a", "# consolidated away", "archived")); + when(skillMapper.selectList(any())).thenReturn(List.of(skill(1L, "a", "# consolidated away", "archived"))); + when(snapshotMapper.selectList(any())).thenReturn(List.of()); + + Map result = service.restore(77L); + + assertEquals(1, result.get("restored")); + assertEquals(0, result.get("missing")); + verify(skillMapper, times(1)).update(eq(null), any()); + } + + @Test + @DisplayName("restore snapshots the current state first, so a rollback is reversible") + void restoreCapturesPreRestorePoint() { + SkillSnapshotEntity snapshot = new SkillSnapshotEntity(); + snapshot.setId(77L); + snapshot.setPayload("[]"); + when(snapshotMapper.selectById(77L)).thenReturn(snapshot); + when(skillMapper.selectList(any())).thenReturn(List.of(skill(1L, "a", "# now", "active"))); + when(snapshotMapper.selectList(any())).thenReturn(List.of()); + + service.restore(77L); + + ArgumentCaptor captured = ArgumentCaptor.forClass(SkillSnapshotEntity.class); + verify(snapshotMapper, atLeastOnce()).insert(captured.capture()); + assertTrue(captured.getValue().getReason().startsWith("pre-restore"), + "rolling back must itself be undoable: " + captured.getValue().getReason()); + } + + @Test + @DisplayName("restore does not resurrect a skill that no longer exists") + void restoreSkipsMissingSkills() { + SkillSnapshotEntity snapshot = new SkillSnapshotEntity(); + snapshot.setId(77L); + snapshot.setPayload("[{\"id\":9,\"name\":\"gone\",\"skillContent\":\"# x\"}]"); + when(snapshotMapper.selectById(77L)).thenReturn(snapshot); + when(skillMapper.selectById(9L)).thenReturn(null); + when(skillMapper.selectList(any())).thenReturn(List.of()); + when(snapshotMapper.selectList(any())).thenReturn(List.of()); + + Map result = service.restore(77L); + + assertEquals(0, result.get("restored")); + assertEquals(1, result.get("missing")); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + @DisplayName("restoring an unknown snapshot is rejected") + void restoreUnknownSnapshot() { + when(snapshotMapper.selectById(404L)).thenReturn(null); + assertThrows(IllegalArgumentException.class, () -> service.restore(404L)); + } + + @Test + @DisplayName("capture prunes snapshots beyond the retention count") + void capturePrunesOldSnapshots() { + properties.setBackupKeep(2); + when(skillMapper.selectList(any())).thenReturn(List.of(skill(1L, "a", "# A", "active"))); + SkillSnapshotEntity s1 = new SkillSnapshotEntity(); + s1.setId(1L); + SkillSnapshotEntity s2 = new SkillSnapshotEntity(); + s2.setId(2L); + SkillSnapshotEntity s3 = new SkillSnapshotEntity(); + s3.setId(3L); + when(snapshotMapper.selectList(any())).thenReturn(List.of(s1, s2, s3)); + + service.capture("pre-sweep"); + + // Newest two kept; the third is pruned. + verify(snapshotMapper, times(1)).deleteById(3L); + verify(snapshotMapper, never()).deleteById(1L); + verify(snapshotMapper, never()).deleteById(2L); + } + + @Test + @DisplayName("listings expose snowflake ids as strings") + void listReturnsStringIds() { + SkillSnapshotEntity row = new SkillSnapshotEntity(); + row.setId(2055137662148763649L); + row.setReason("pre-sweep"); + row.setSkillCount(3); + when(snapshotMapper.selectList(any())).thenReturn(List.of(row)); + + List> out = service.list(20); + + assertEquals("2055137662148763649", out.get(0).get("id"), + "a 19-digit id must not round-trip through a JS Number"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/model/SkillOriginTest.java b/mateclaw-server/src/test/java/vip/mate/skill/model/SkillOriginTest.java new file mode 100644 index 00000000..b35657d0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/model/SkillOriginTest.java @@ -0,0 +1,47 @@ +package vip.mate.skill.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the curation policy flag. The codes are persisted values and the + * curator's candidate query is built from {@link SkillOrigin#curatorManagedCodes()}, + * so a change here silently changes which skills get archived. + */ +class SkillOriginTest { + + @Test + @DisplayName("persisted codes are stable") + void codesAreStable() { + assertEquals("user", SkillOrigin.USER.code()); + assertEquals("agent", SkillOrigin.AGENT.code()); + assertEquals("routine", SkillOrigin.ROUTINE.code()); + } + + @Test + @DisplayName("user-authored skills are off-limits to autonomous curation") + void userSkillsAreNotCuratorManaged() { + assertFalse(SkillOrigin.USER.curatorManaged()); + } + + @Test + @DisplayName("autonomously-written skills are curator-managed") + void autonomousSkillsAreCuratorManaged() { + assertTrue(SkillOrigin.AGENT.curatorManaged()); + assertTrue(SkillOrigin.ROUTINE.curatorManaged()); + } + + @Test + @DisplayName("the curator candidate filter covers exactly the autonomous origins") + void managedCodesMatchTheFlag() { + for (SkillOrigin origin : SkillOrigin.values()) { + assertEquals(origin.curatorManaged(), + SkillOrigin.curatorManagedCodes().contains(origin.code()), + origin + " must appear in curatorManagedCodes() iff it is curator-managed"); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java index 102a6532..18431062 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java @@ -12,6 +12,7 @@ import org.springframework.ai.chat.prompt.Prompt; import vip.mate.agent.AgentGraphBuilder; import vip.mate.llm.service.ModelConfigService; import vip.mate.skill.service.SkillService; +import vip.mate.skill.model.SkillOrigin; import vip.mate.tool.builtin.SkillManageTool; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageEntity; @@ -85,7 +86,7 @@ class SkillReflectionServiceTest { properties.setEnabled(false); service.maybeReflect(1L, "conv-1", 8); verify(conversationService, never()).listMessages(any()); - verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test @@ -104,24 +105,24 @@ class SkillReflectionServiceTest { when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(1)); service.maybeReflect(1L, "conv-1", 8); verify(agentGraphBuilder, never()).buildRuntimeChatModel(any()); - verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test - @DisplayName("happy path: a create action routes through skill_manage") + @DisplayName("happy path: a create action routes through the autonomous skill_manage entry point") void appliesCreateAction() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); stubLlm("[{\"action\":\"create\",\"name\":\"spring-scaffold\",\"reason\":\"reusable\"," + "\"content\":\"---\\nname: spring-scaffold\\n---\\n# X\"}]"); - when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + when(skillManageTool.skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any())) .thenReturn("Skill 'spring-scaffold' created successfully (security scan: PASSED)."); service.maybeReflect(1L, "conv-1", 8); verify(skillManageTool, times(1)) - .skill_manage(eq("create"), eq("spring-scaffold"), any(), any(), any(), any(), any()); + .skillManageAs(eq(SkillOrigin.AGENT), eq("create"), eq("spring-scaffold"), any(), any(), any(), any(), any()); } @Test @@ -134,7 +135,7 @@ class SkillReflectionServiceTest { service.maybeReflect(1L, "conv-1", 8); - verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test @@ -148,12 +149,44 @@ class SkillReflectionServiceTest { stubLlm("[{\"action\":\"create\",\"name\":\"s1\"," + body + "}," + "{\"action\":\"create\",\"name\":\"s2\"," + body + "}," + "{\"action\":\"create\",\"name\":\"s3\"," + body + "}]"); - when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + when(skillManageTool.skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any())) .thenReturn("created successfully"); service.maybeReflect(1L, "conv-1", 8); - verify(skillManageTool, times(2)).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(skillManageTool, times(2)).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("cadence gate: a message count that steps over the interval still reviews") + void cadenceGateSurvivesSkippedCounts() { + // The published count is the conversation total and can jump by more + // than one per event (batched persistence, tool messages, channel + // replays). A review must still fire when the count steps straight + // over an exact multiple of the interval. + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(2); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); + stubLlm("[]"); + + service.maybeReflect(1L, "conv-1", 11); + + verify(conversationService, times(1)).listMessages("conv-1"); + } + + @Test + @DisplayName("cadence gate: an attempt blocked by the floor waits a full interval") + void floorBlockedAttemptStillAdvancesTheMark() { + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(5); + properties.setCooldownMinutes(0); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(1)); + + service.maybeReflect(1L, "conv-1", 8); + // Only three further messages — below the interval, so no re-check. + service.maybeReflect(1L, "conv-1", 11); + + verify(conversationService, times(1)).listMessages("conv-1"); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/skill/routine/SkillRoutineMinerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/routine/SkillRoutineMinerTest.java new file mode 100644 index 00000000..c6b90e11 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/routine/SkillRoutineMinerTest.java @@ -0,0 +1,134 @@ +package vip.mate.skill.routine; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.common.text.Shingles; +import vip.mate.skill.routine.repository.SkillRoutineCandidateMapper; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Tests for the deterministic half of routine mining — opener normalization + * and clustering. These are the parts that decide whether two runs of the same + * habitual request are recognised as the same routine. + */ +class SkillRoutineMinerTest { + + private SkillRoutineProperties properties; + private SkillRoutineMiner miner; + + @BeforeEach + void setUp() { + properties = new SkillRoutineProperties(); + miner = new SkillRoutineMiner( + mock(ConversationMapper.class), + mock(MessageMapper.class), + mock(SkillRoutineCandidateMapper.class), + properties, + new ObjectMapper()); + } + + private SkillRoutineMiner.Opener opener(String text, int dayOffset) { + String normalized = miner.normalize(text); + return new SkillRoutineMiner.Opener( + "conv-" + text.hashCode() + "-" + dayOffset, + 1L, 1L, text, normalized, Shingles.of(normalized), + LocalDateTime.of(2026, 8, 1, 9, 0).plusDays(dayOffset)); + } + + @Test + @DisplayName("normalize strips the values that vary between runs of one routine") + void normalizeStripsVaryingValues() { + String a = miner.normalize("Generate the 2026-08-04 ops report"); + String b = miner.normalize("Generate the 2026-08-05 ops report"); + assertEquals(a, b, "dates must not distinguish two runs of the same routine"); + } + + @Test + @DisplayName("normalize drops URLs and filesystem paths") + void normalizeDropsUrlsAndPaths() { + String n = miner.normalize("Summarize https://example.com/x and /var/log/app.log please"); + assertTrue(n.contains("summarize"), "intent words survive: " + n); + assertTrue(!n.contains("example") && !n.contains("var"), + "URL and path tokens must be stripped: " + n); + } + + @Test + @DisplayName("Chinese openers cluster without a word segmenter") + void clustersChineseOpeners() { + List openers = new ArrayList<>(List.of( + opener("帮我生成今天的运维日报", 0), + opener("帮我生成今天的运维日报,谢谢", 1), + opener("生成今天的运维日报", 2))); + + List clusters = miner.cluster(openers); + + assertEquals(1, clusters.size(), "three phrasings of one request must form one cluster"); + assertEquals(3, clusters.get(0).members().size()); + assertEquals(3, clusters.get(0).distinctDays()); + } + + @Test + @DisplayName("unrelated requests stay in separate clusters") + void keepsUnrelatedRequestsApart() { + List openers = new ArrayList<>(List.of( + opener("帮我生成今天的运维日报", 0), + opener("把这段代码重构成异步实现", 1), + opener("查一下上个季度的营收数字", 2))); + + List clusters = miner.cluster(openers); + + assertEquals(3, clusters.size(), "distinct intents must not be merged"); + } + + @Test + @DisplayName("English openers cluster on shared word tokens") + void clustersEnglishOpeners() { + List openers = new ArrayList<>(List.of( + opener("generate the weekly oncall digest for the team", 0), + opener("generate the weekly oncall digest for the team now", 3))); + + List clusters = miner.cluster(openers); + + assertEquals(1, clusters.size()); + assertEquals(2, clusters.get(0).distinctDays()); + } + + @Test + @DisplayName("distinctDays counts calendar days, not occurrences") + void distinctDaysIgnoresSameDayRetries() { + // Five conversations in one afternoon is one person retrying, not a habit. + List sameDay = new ArrayList<>(List.of( + opener("帮我生成今天的运维日报", 0), + opener("帮我生成今天的运维日报", 0), + opener("帮我生成今天的运维日报", 0))); + + List clusters = miner.cluster(sameDay); + + assertEquals(1, clusters.size()); + assertEquals(3, clusters.get(0).members().size()); + assertEquals(1, clusters.get(0).distinctDays(), + "same-day retries must not satisfy the habit gate"); + } + + @Test + @DisplayName("a raised similarity threshold splits loosely-related openers") + void thresholdControlsMergeAggressiveness() { + properties.setSimilarityThreshold(0.95); + List openers = new ArrayList<>(List.of( + opener("generate the weekly oncall digest for the team", 0), + opener("generate the weekly oncall digest for the team now", 1))); + + assertEquals(2, miner.cluster(openers).size()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java index 5bb236cd..11874b65 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java @@ -3,6 +3,7 @@ package vip.mate.tool.builtin; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillSecurityService; @@ -43,7 +44,9 @@ class SkillManageToolWriteFileTest { securityService = mock(SkillSecurityService.class); workspaceManager = mock(SkillWorkspaceManager.class); SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); - tool = new SkillManageTool(skillService, skillFileService, securityService, workspaceManager, runtimeService); + ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + tool = new SkillManageTool(skillService, skillFileService, securityService, workspaceManager, + runtimeService, eventPublisher); } private SkillEntity skill(String name, boolean builtin) { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f4945d60..30a1ae6c 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -324,6 +324,32 @@ export const skillApi = { curatorReports: () => http.get('/skills/curator/reports'), /** Read one curator run report (parsed run.json). */ curatorReport: (runId: string) => http.get(`/skills/curator/reports/${runId}`), + + // ---- Curator restore points ---- + /** List recent skill-library restore points (newest first). */ + curatorSnapshots: () => http.get('/skills/curator/snapshots'), + /** Capture a restore point on demand. */ + curatorSnapshotCapture: (reason?: string) => + http.post('/skills/curator/snapshots', null, { params: reason ? { reason } : {} }), + /** + * Roll the skill library back to a restore point. The id stays a string — + * 19-digit snowflake ids lose precision as a JS number. + */ + curatorSnapshotRestore: (snapshotId: string) => + http.post(`/skills/curator/snapshots/${snapshotId}/restore`), + + // ---- Routine mining ---- + /** Mined recurring-request candidates plus the promotion thresholds. */ + routines: (status?: string) => + http.get('/skills/routines', { params: status ? { status } : {} }), + /** Run a mining sweep now instead of waiting for the nightly job. */ + routineMine: () => http.post('/skills/routines/mine'), + /** Reject a candidate so later sweeps stop re-detecting it. */ + routineDismiss: (id: string) => http.post(`/skills/routines/${id}/dismiss`), + /** Put a dismissed candidate back under observation. */ + routineReopen: (id: string) => http.post(`/skills/routines/${id}/reopen`), + /** Synthesize the skill now, bypassing the recurrence thresholds. */ + routinePromote: (id: string) => http.post(`/skills/routines/${id}/promote`), } /** Shape returned by GET /skills/{id}/secrets. */ diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 17d6c975..781c1e57 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -4180,6 +4180,39 @@ export default { enableConsolidate: 'Enable merge', disableConsolidate: 'Disable merge', consolidateHint: 'Consolidation spends one LLM call to merge highly-overlapping agent-created skills into a broader one; absorbed skills are archived (recoverable). Off by default.', + routines: 'Routine mining', + routinesHint: 'A nightly pass clusters the opening request of each conversation to find the ones you make habitually. A cluster is only promoted to a skill once it clears both gates — seen {occurrences} times AND across {days} distinct days. Occurrences prove repetition; distinct days prove a habit rather than one afternoon of retries.', + noRoutines: 'No candidates yet', + routineMine: 'Mine now', + routineMineSuccess: 'Mining complete — {n} candidate(s) refreshed', + routineFilterObserving: 'Observing', + routineFilterPromoted: 'Promoted', + routineFilterDismissed: 'Dismissed', + routineFilterAll: 'All', + routineStatusObserving: 'Observing', + routineStatusReady: 'Ready to promote', + routineStatusPromoted: 'Promoted', + routineStatusDismissed: 'Dismissed', + routineOccurrences: '{n}x', + routineDays: '{n} days', + routineLastSeen: 'Last seen', + routinePromote: 'Promote to skill', + routinePromoteEarly: 'Promote early', + routinePromoteEarlyConfirm: 'This candidate has only been seen {occurrences} times across {days} days, below the automatic threshold. Promoting spends an LLM call and writes a skill the agent will consult from then on. Continue?', + routinePromoteSuccess: 'Promoted to a skill', + routineDismiss: 'Dismiss', + routineDismissSuccess: 'Dismissed — later sweeps will not reopen it', + routineReopen: 'Reopen', + routineReopenSuccess: 'Back under observation', + snapshots: 'Restore points', + snapshotsHint: 'A restore point is captured before every sweep that actually applies changes (previews need none). Sweeps archive skills and, with consolidation on, rewrite skill bodies — unattended, so a bad pass is usually noticed long after it ran. Rolling back is itself snapshotted first.', + noSnapshots: 'No restore points yet', + snapshotCapture: 'Capture now', + snapshotCaptureSuccess: 'Restore point captured', + snapshotSkillCount: '{n} skills', + snapshotRestore: 'Roll back to this', + snapshotRestoreConfirm: 'This rewrites every skill body and lifecycle state back to how they were at {time} ({n} skills), overwriting the current content. A restore point is captured first, so this is itself undoable. Continue?', + snapshotRestoreSuccess: 'Restored {restored} skill(s); {missing} no longer present and skipped', consolidateCreate: 'new', consolidateEdit: 'edit', activateSuccess: 'Skill curator activated', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 2a294fba..3680e492 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -4272,6 +4272,39 @@ export default { enableConsolidate: '开启合并', disableConsolidate: '关闭合并', consolidateHint: '合并去重会用一次 LLM 调用,把高度重复的自建技能合并成一个更通用的技能,被合并的技能将被归档(可恢复)。默认关闭。', + routines: '例行事项挖掘', + routinesHint: '每晚扫描各个会话的首条请求,把你反复提出的相同请求聚成一类。同时满足「出现 {occurrences} 次」和「跨 {days} 天」两个条件才会自动合成技能——次数证明重复,跨天证明是习惯而非一个下午的反复重试。', + noRoutines: '暂无候选', + routineMine: '立即挖掘', + routineMineSuccess: '挖掘完成,更新了 {n} 个候选', + routineFilterObserving: '观察中', + routineFilterPromoted: '已合成', + routineFilterDismissed: '已忽略', + routineFilterAll: '全部', + routineStatusObserving: '观察中', + routineStatusReady: '达标待合成', + routineStatusPromoted: '已合成', + routineStatusDismissed: '已忽略', + routineOccurrences: '{n} 次', + routineDays: '跨 {n} 天', + routineLastSeen: '最近', + routinePromote: '合成技能', + routinePromoteEarly: '提前合成', + routinePromoteEarlyConfirm: '该候选目前只出现 {occurrences} 次、跨 {days} 天,尚未达到自动合成门槛。提前合成会消耗一次 LLM 调用,并写入一个 Agent 之后每次都会参考的技能。确认继续?', + routinePromoteSuccess: '已合成为技能', + routineDismiss: '忽略', + routineDismissSuccess: '已忽略,后续挖掘不会再重开', + routineReopen: '重新观察', + routineReopenSuccess: '已重新纳入观察', + snapshots: '还原点', + snapshotsHint: '每次真正执行(非预览)的整理前会自动留一个还原点。整理会归档技能,开启合并去重后还会重写技能正文,且都在无人值守时进行——没有还原点这些改动就是单向的。回滚本身也会先留一个还原点。', + noSnapshots: '暂无还原点', + snapshotCapture: '立即捕获', + snapshotCaptureSuccess: '已捕获还原点', + snapshotSkillCount: '{n} 个技能', + snapshotRestore: '回滚到此', + snapshotRestoreConfirm: '将把所有技能的正文和生命周期状态回滚到 {time} 的状态(共 {n} 个技能),当前内容会被覆盖。回滚前会自动留一个还原点,所以此操作可以再撤销。确认继续?', + snapshotRestoreSuccess: '已恢复 {restored} 个技能,{missing} 个已不存在而跳过', consolidateCreate: '新建', consolidateEdit: '更新', activateSuccess: '技能管家已激活', diff --git a/mateclaw-ui/src/views/Settings/SkillCurator/index.vue b/mateclaw-ui/src/views/Settings/SkillCurator/index.vue index 20155147..fa205910 100644 --- a/mateclaw-ui/src/views/Settings/SkillCurator/index.vue +++ b/mateclaw-ui/src/views/Settings/SkillCurator/index.vue @@ -97,6 +97,97 @@ + +

+
+

{{ t('skillCurator.routines') }}

+
+ +
+
+

+ {{ t('skillCurator.routinesHint', { occurrences: gates.minOccurrences, days: gates.minDistinctDays }) }} +

+ +
+ +
+ +

{{ t('skillCurator.noRoutines') }}

+
    +
  • +
    +

    {{ r.representativeText || r.signature }}

    +
    + {{ routineStatusLabel(r) }} + {{ t('skillCurator.routineOccurrences', { n: r.occurrenceCount }) }} + {{ t('skillCurator.routineDays', { n: r.distinctDayCount }) }} + {{ t('skillCurator.routineLastSeen') }}: {{ r.lastSeenAt }} + + → {{ r.promotedSkillName }} + +
    +
    +
    + + + +
    +
  • +
+
+ + +
+
+

{{ t('skillCurator.snapshots') }}

+
+ +
+
+

{{ t('skillCurator.snapshotsHint') }}

+ +

{{ t('skillCurator.noSnapshots') }}

+
    +
  • +
    +

    {{ s.reason }}

    +
    + {{ s.createdAt }} + {{ t('skillCurator.snapshotSkillCount', { n: s.skillCount }) }} +
    +
    +
    + +
    +
  • +
+
+

{{ t('skillCurator.reports') }}

@@ -148,6 +239,7 @@ @@ -333,7 +617,26 @@ onMounted(load) .report-transition { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--mc-text-primary); padding: 4px 0; } .report-transition code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; } +.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.card-head .card-title { margin-bottom: 0; } + +.filter-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } +.filter-chip { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; } +.filter-chip:hover { background: var(--mc-bg-sunken); } +.filter-chip.active { border-color: var(--mc-primary); color: var(--mc-primary); } + +.routine-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.routine-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--mc-border-light); } +.routine-item:last-child { border-bottom: none; } +.routine-main { min-width: 0; flex: 1; } +.routine-text { margin: 0 0 6px; font-size: 14px; font-weight: 600; color: var(--mc-text-primary); overflow-wrap: anywhere; } +.routine-meta { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; font-size: 12px; color: var(--mc-text-tertiary); } +.routine-skill code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; color: var(--mc-text-secondary); } +.routine-actions { display: flex; gap: 8px; flex-shrink: 0; flex-wrap: wrap; } + @media (max-width: 900px) { + .routine-item { flex-direction: column; gap: 10px; } + .routine-actions { width: 100%; } .kv-row { flex-direction: column; gap: 2px; } }