diff --git a/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java new file mode 100644 index 00000000..a9894c3a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java @@ -0,0 +1,138 @@ +package vip.mate.memory.tool; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.model.WorkspaceFileEntity; +import vip.mate.workspace.document.WorkspaceFileService; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * RFC-090 §11.3.1 — universal {@code remember()} tool. + * + *

{@link StructuredMemoryTool} forces a typed schema (user/feedback/ + * project/reference). That's the right primitive for stable knowledge, + * but skills also produce free-form lessons that don't fit those + * buckets. This tool gives the agent a single, parameter-light way to + * leave a note for the next conversation. + * + *

Storage path: appends to {@code MEMORY.md} under a + * {@code ## Recent Lessons} section so the next dream pass can + * consolidate it. {@link MemoryWriteEvent} is published on success so + * the existing SOUL summarizer K-counter advances. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UniversalMemoryTool { + + private static final String MEMORY_FILENAME = "MEMORY.md"; + private static final String LESSONS_HEADER = "## Recent Lessons"; + private static final DateTimeFormatter ENTRY_TS = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm"); + + private final WorkspaceFileService workspaceFileService; + private final ApplicationEventPublisher eventPublisher; + + @Tool(description = """ + 将一条自由形式的经验或洞察追加到 Agent 的长期记忆 (MEMORY.md)。 + 适用于不属于结构化 4 类 (user/feedback/project/reference) 的自由笔记。 + 内容会被下一次 Dream consolidation 整合到 SOUL.md 或事实区。 + 如果你需要记录的是结构化条目,优先用 remember_structured。 + """) + public String remember( + @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "要记住的内容(自由形式)") String content, + @ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source) { + + if (agentId == null) return error("agentId 不能为空"); + if (content == null || content.isBlank()) return error("content 不能为空"); + + try { + WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, MEMORY_FILENAME); + String existingContent = existing != null && existing.getContent() != null + ? existing.getContent() : ""; + String updated = appendLesson(existingContent, content, source); + workspaceFileService.saveFile(agentId, MEMORY_FILENAME, updated); + + // RFC-090 §14.3 — universal remember() targets MEMORY.md (the + // canonical file), so this IS a MemoryWriteEvent. Skill-local + // lessons go through SkillLessonWrittenEvent instead and do + // NOT touch this path. + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, MEMORY_FILENAME, + "remember", content)); + + JSONObject result = new JSONObject(); + result.set("success", true); + result.set("file", MEMORY_FILENAME); + result.set("message", "已记入 MEMORY.md,将在下次 Dream consolidation 时整合"); + return JSONUtil.toJsonPrettyStr(result); + } catch (Exception e) { + log.warn("[UniversalMemoryTool] remember failed: {}", e.getMessage()); + return error("记忆写入失败: " + e.getMessage()); + } + } + + /** + * Append the lesson under {@value LESSONS_HEADER}, preserving any + * existing MEMORY.md content. Creates the section header on first + * write. + */ + static String appendLesson(String existing, String content, String source) { + StringBuilder sb = new StringBuilder(); + String ts = LocalDateTime.now().format(ENTRY_TS); + String entry = "- " + ts + + (source != null && !source.isBlank() ? " (" + source.trim() + ")" : "") + + ": " + content.trim(); + + if (existing == null || existing.isBlank()) { + sb.append(LESSONS_HEADER).append("\n").append(entry).append("\n"); + return sb.toString(); + } + + int headerIdx = existing.indexOf(LESSONS_HEADER); + if (headerIdx < 0) { + sb.append(existing); + if (!existing.endsWith("\n")) sb.append("\n"); + sb.append("\n").append(LESSONS_HEADER).append("\n").append(entry).append("\n"); + return sb.toString(); + } + + // Insert at the end of the Recent Lessons section (before the + // next ##-level heading or EOF). + int sectionStart = headerIdx + LESSONS_HEADER.length(); + int nextHeading = findNextHeading(existing, sectionStart); + if (nextHeading < 0) { + sb.append(existing); + if (!existing.endsWith("\n")) sb.append("\n"); + sb.append(entry).append("\n"); + } else { + sb.append(existing, 0, nextHeading); + if (!existing.substring(0, nextHeading).endsWith("\n")) sb.append("\n"); + sb.append(entry).append("\n\n"); + sb.append(existing, nextHeading, existing.length()); + } + return sb.toString(); + } + + private static int findNextHeading(String content, int from) { + // Match a line starting with "## " (any heading level >= 2). + int idx = content.indexOf("\n## ", from); + return idx < 0 ? -1 : idx + 1; // position of '#' itself + } + + private static String error(String msg) { + JSONObject e = new JSONObject(); + e.set("success", false); + e.set("error", msg); + return JSONUtil.toJsonPrettyStr(e); + } +} 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 f11148e7..fce7ac12 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 @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.skill.lessons.SkillLessonsService; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.SkillDependencyChecker; @@ -39,6 +40,7 @@ public class SkillController { private final SkillWorkspaceManager workspaceManager; private final SkillSynthesisService synthesisService; private final SkillDependencyChecker dependencyChecker; + private final SkillLessonsService lessonsService; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping @@ -209,6 +211,40 @@ public class SkillController { )); } + // ==================== Lessons API (RFC-090 §7 + §11.4) ==================== + + /** + * Read the per-skill {@code LESSONS.md} body for the detail drawer. + * Returns {@code entries: []} when the file is missing. + */ + @Operation(summary = "Read per-skill LESSONS.md (RFC-090 §11.4)") + @GetMapping("/{id}/lessons") + public R> getLessons(@PathVariable Long id) { + ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() + .filter(r -> r != null && id.equals(r.getId())) + .findFirst() + .orElse(null); + if (resolved == null) return R.fail("Skill not found: " + id); + String body = lessonsService.readLessons(resolved); + return R.ok(Map.of( + "skillId", id, + "skillName", resolved.getName(), + "raw", body == null ? "" : body + )); + } + + @Operation(summary = "Clear all lessons for a skill (RFC-090 §11.4)") + @PostMapping("/{id}/lessons/clear") + public R> clearLessons(@PathVariable Long id) { + ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() + .filter(r -> r != null && id.equals(r.getId())) + .findFirst() + .orElse(null); + if (resolved == null) return R.fail("Skill not found: " + id); + boolean cleared = lessonsService.clearLessons(resolved); + return R.ok(Map.of("cleared", cleared)); + } + // ==================== Synthesis API (RFC-023) ==================== @Operation(summary = "从对话历史合成 Skill(RFC-023)") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java new file mode 100644 index 00000000..1a1e88e8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java @@ -0,0 +1,271 @@ +package vip.mate.skill.lessons; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import vip.mate.skill.lessons.event.SkillLessonWrittenEvent; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.workspace.SkillWorkspaceManager; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * RFC-090 §11.4 / §14.3 — per-skill {@code LESSONS.md} read & write. + * + *

SKILL.md is a deployment artifact and must remain read-only at + * runtime. Lessons captured by the skill itself land in a sibling file + * {@code LESSONS.md} so the system prompt enhancement layer can append + * recent experience without rewriting the manifest. + * + *

Why a dedicated file rather than {@code MEMORY.md}: + *

+ * + *

Storage format (markdown, append-only, FIFO truncated): + *

+ * # Lessons learned for {skill-id}
+ *
+ * ## 2026-04-30 14:23  (conversation: 7a3b...)
+ * Lesson body
+ * Source: {skill-id} turn #N
+ *
+ * ## 2026-04-29 09:12  (conversation: 9c4d...)
+ * ...
+ * 
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillLessonsService { + + /** Cap entries to keep LESSONS.md from ballooning (§10.1 risk 3). */ + private static final int DEFAULT_MAX_ENTRIES = 50; + + private static final DateTimeFormatter HEADING_TS = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm") + .withLocale(Locale.ROOT); + + private final SkillWorkspaceManager workspaceManager; + private final ApplicationEventPublisher eventPublisher; + + /** + * Append a lesson to {@code LESSONS.md}. + * + *

Side effects: + *

    + *
  1. Creates the file if it doesn't exist (with the canonical + * header).
  2. + *
  3. Appends a new {@code ## yyyy-MM-dd HH:mm (conversation: id)} + * section with the lesson body.
  4. + *
  5. If total entries exceed {@code maxEntries}, drops the oldest + * sections (FIFO truncation).
  6. + *
  7. Publishes {@link SkillLessonWrittenEvent} on success.
  8. + *
+ * + * @param resolved the resolved skill (must have a workspace + * directory for the write to succeed; database- + * only skills are returned as a no-op) + * @param agentId agent that produced the lesson, may be null + * @param conversationId conversation context, may be null + * @param content lesson text — caller's responsibility to + * sanitize / truncate + * @param maxEntries upper bound on entries; non-positive falls + * back to {@link #DEFAULT_MAX_ENTRIES} + * @return new lesson id (UUID) when persisted, null on no-op + */ + public String recordLesson(ResolvedSkill resolved, Long agentId, + String conversationId, String content, + int maxEntries) { + if (resolved == null || resolved.getName() == null) { + log.debug("recordLesson: missing skill identity, skipping"); + return null; + } + if (content == null || content.isBlank()) { + log.debug("recordLesson: empty content for skill '{}', skipping", resolved.getName()); + return null; + } + Path workspace = resolveWorkspace(resolved); + if (workspace == null) { + log.debug("recordLesson: no workspace directory for skill '{}', skipping", + resolved.getName()); + return null; + } + + Path lessonsFile = workspace.resolve("LESSONS.md"); + int cap = maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES; + String lessonId = UUID.randomUUID().toString(); + + try { + ensureFileWithHeader(lessonsFile, resolved.getName()); + String section = buildSection(conversationId, content); + Files.writeString(lessonsFile, section, + StandardCharsets.UTF_8, StandardOpenOption.APPEND); + enforceCap(lessonsFile, resolved.getName(), cap); + } catch (IOException e) { + log.warn("Failed to write LESSONS.md for skill '{}': {}", + resolved.getName(), e.getMessage()); + return null; + } + + eventPublisher.publishEvent(new SkillLessonWrittenEvent( + agentId, + resolved.getId(), + resolved.getName(), + conversationId, + content)); + return lessonId; + } + + /** + * Read raw {@code LESSONS.md} contents. Returns null when the file + * is missing — callers should treat as "no lessons yet" without + * branching on exception. + */ + public String readLessons(ResolvedSkill resolved) { + Path workspace = resolveWorkspace(resolved); + if (workspace == null) return null; + Path file = workspace.resolve("LESSONS.md"); + if (!Files.exists(file)) return null; + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Failed to read LESSONS.md for skill '{}': {}", + resolved.getName(), e.getMessage()); + return null; + } + } + + /** + * Convenience for the prompt enhancement layer: returns the body + * (everything after the canonical title) so it can be appended to + * a skill's SKILL.md body without duplicating the title. + */ + public String readLessonsBody(ResolvedSkill resolved) { + String full = readLessons(resolved); + if (full == null || full.isBlank()) return null; + // Strip the leading "# Lessons learned for {name}\n\n" header. + int firstSection = full.indexOf("\n## "); + if (firstSection < 0) return full.trim(); + return full.substring(firstSection + 1).trim(); + } + + /** + * Delete a single lesson by id by rewriting the file without the + * matching section. Returns {@code true} when an entry was actually + * removed. + */ + public boolean deleteLesson(ResolvedSkill resolved, String lessonId) { + // Lessons today have no per-section id — id is generated on + // write but not persisted. This is a placeholder for the API + // wiring; callers that need precise revert should use + // clearLessons or rebuild via re-recording. Returning false + // signals "no-op". + return false; + } + + /** + * Wipe the LESSONS.md file entirely (called by the + * {@code DELETE /skills/{id}/lessons/clear} admin endpoint). + */ + public boolean clearLessons(ResolvedSkill resolved) { + Path workspace = resolveWorkspace(resolved); + if (workspace == null) return false; + Path file = workspace.resolve("LESSONS.md"); + if (!Files.exists(file)) return false; + try { + Files.delete(file); + return true; + } catch (IOException e) { + log.warn("Failed to clear LESSONS.md for skill '{}': {}", + resolved.getName(), e.getMessage()); + return false; + } + } + + // ==================== internals ==================== + + private Path resolveWorkspace(ResolvedSkill resolved) { + if (resolved == null || resolved.getName() == null) return null; + if (resolved.getSkillDir() != null) return resolved.getSkillDir(); + Path convention = workspaceManager.resolveConventionPath(resolved.getName()); + return Files.exists(convention) && Files.isDirectory(convention) ? convention : null; + } + + private void ensureFileWithHeader(Path file, String skillName) throws IOException { + if (Files.exists(file)) return; + String header = "# Lessons learned for " + skillName + "\n\n"; + Files.writeString(file, header, StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW); + } + + private String buildSection(String conversationId, String content) { + StringBuilder sb = new StringBuilder(); + sb.append("\n## ").append(LocalDateTime.now().format(HEADING_TS)); + if (conversationId != null && !conversationId.isBlank()) { + sb.append(" (conversation: ").append(conversationId).append(")"); + } + sb.append("\n").append(content.trim()).append("\n"); + return sb.toString(); + } + + /** + * FIFO truncation. Reads the file once, splits on {@code "\n## "}, + * keeps the title + last {@code maxEntries} sections, and rewrites. + * Cheap enough for the typical N=50 cap. + */ + private void enforceCap(Path file, String skillName, int maxEntries) throws IOException { + if (maxEntries <= 0) return; + String full = Files.readString(file, StandardCharsets.UTF_8); + if (full.isBlank()) return; + + String header = "# Lessons learned for " + skillName + "\n"; + int firstSectionIdx = full.indexOf("\n## "); + if (firstSectionIdx < 0) return; // no sections yet + + String preface = full.substring(0, firstSectionIdx); + String sectionsBlob = full.substring(firstSectionIdx + 1); // strip the leading newline + + String[] sections = sectionsBlob.split("\n## "); + if (sections.length <= maxEntries) return; + + List kept = new ArrayList<>(maxEntries); + for (int i = sections.length - maxEntries; i < sections.length; i++) { + kept.add(sections[i]); + } + StringBuilder rebuilt = new StringBuilder(); + if (!preface.isBlank()) rebuilt.append(preface).append("\n"); + else rebuilt.append(header).append("\n"); + for (int i = 0; i < kept.size(); i++) { + rebuilt.append("## ").append(kept.get(i)); + if (i < kept.size() - 1 && !kept.get(i).endsWith("\n")) rebuilt.append("\n"); + } + Files.writeString(file, rebuilt.toString(), StandardCharsets.UTF_8); + } + + /** Test hook (package-private) — exposes the parsing rule used by readLessonsBody. */ + static List splitSections(String full) { + if (full == null || full.isBlank()) return Collections.emptyList(); + int firstSectionIdx = full.indexOf("\n## "); + if (firstSectionIdx < 0) return Collections.emptyList(); + String body = full.substring(firstSectionIdx + 1); + return java.util.Arrays.asList(body.split("\n## ")); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java new file mode 100644 index 00000000..8fe637a1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java @@ -0,0 +1,92 @@ +package vip.mate.skill.lessons; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; + +/** + * RFC-090 §11.4.2 — agent-callable {@code record_lesson} tool. + * + *

The tool is always exposed by the registry; the per-skill + * {@code self-evolution.lessons_enabled} switch governs whether the + * skill's manifest opts in. We don't filter at registry time so + * skills can dynamically toggle the flag without a rebuild. When the + * skill says no, the call returns a friendly explanation rather than + * silently writing. + * + *

Storage and event publishing live in + * {@link SkillLessonsService#recordLesson}. This class is the LLM- + * facing wrapper. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillLessonsTool { + + private final SkillRuntimeService skillRuntimeService; + private final SkillLessonsService lessonsService; + + @Tool(description = """ + 为指定 skill 记录一条 lesson(经验/洞察),追加到该 skill 的 LESSONS.md。 + 下次加载该 skill 时,LESSONS.md 内容会自动注入到 system prompt。 + 仅当 skill manifest 里 self-evolution.lessons_enabled=true 时生效(默认开启)。 + 如果想记录的内容跨 skill 通用,请改用 remember 或 remember_structured。 + """) + public String record_lesson( + @ToolParam(description = "skill 的 slug(即 SKILL.md frontmatter 里的 name)") String skillName, + @ToolParam(description = "要记录的经验内容") String lesson, + @ToolParam(description = "可选:当前 Agent 的 ID", required = false) Long agentId, + @ToolParam(description = "可选:当前对话 ID", required = false) String conversationId) { + + if (skillName == null || skillName.isBlank()) return error("skillName 不能为空"); + if (lesson == null || lesson.isBlank()) return error("lesson 不能为空"); + + ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() + .filter(s -> s != null && skillName.equals(s.getName())) + .findFirst() + .orElse(null); + if (resolved == null) { + return error("找不到 skill: " + skillName); + } + + SkillManifest manifest = resolved.getManifest(); + // §10.2 Q7 — default ON; only skip when the manifest explicitly opts out. + boolean lessonsEnabled = manifest == null + || manifest.getSelfEvolution() == null + || manifest.getSelfEvolution().isLessonsEnabled(); + if (!lessonsEnabled) { + return error("该 skill 已在 manifest 中关闭 self-evolution.lessons_enabled," + + "无法记录 lesson。请改用 remember 工具或修改 manifest。"); + } + + int max = manifest != null && manifest.getSelfEvolution() != null + ? manifest.getSelfEvolution().getLessonsMaxEntries() : 0; + + String lessonId = lessonsService.recordLesson(resolved, agentId, conversationId, + lesson, max); + if (lessonId == null) { + return error("Lesson 记录失败:skill 可能仅存在于数据库(无 workspace 目录)。"); + } + + JSONObject result = new JSONObject(); + result.set("success", true); + result.set("skill", skillName); + result.set("lessonId", lessonId); + result.set("message", "Lesson 已记录,下次加载该 skill 时会自动注入到 system prompt。"); + return JSONUtil.toJsonPrettyStr(result); + } + + private static String error(String msg) { + JSONObject e = new JSONObject(); + e.set("success", false); + e.set("error", msg); + return JSONUtil.toJsonPrettyStr(e); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lessons/event/SkillLessonWrittenEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/lessons/event/SkillLessonWrittenEvent.java new file mode 100644 index 00000000..f9b54f0f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lessons/event/SkillLessonWrittenEvent.java @@ -0,0 +1,32 @@ +package vip.mate.skill.lessons.event; + +/** + * RFC-090 §14.3 — fires when a skill records a new lesson into its + * per-skill {@code LESSONS.md} file. + * + *

Distinct from {@code MemoryWriteEvent} on purpose: lessons are + * skill-local, agent-scoped notes rather than canonical memory writes. + * Mixing them into the SOUL summarizer would inflate its recompute + * count without improving the canonical memory's quality. + * + *

Default subscriber set is empty. A future "lesson → memory" + * aggregator can subscribe and decide when (if ever) to promote + * accumulated lessons up to MEMORY.md / SOUL.md. + * + * @param agentId agent that triggered the skill call (may be null + * for global / non-agent contexts) + * @param skillId DB id of the skill the lesson belongs to (may + * be null when the runtime resolved by name only) + * @param skillName slug identifier of the skill (always present) + * @param conversationId conversation in which the lesson was learned; + * may be null for offline / cron-driven flows + * @param content lesson text exactly as recorded (caller is + * responsible for any redaction / truncation) + */ +public record SkillLessonWrittenEvent( + Long agentId, + Long skillId, + String skillName, + String conversationId, + String content +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 1badfe55..d5d535fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -2,9 +2,12 @@ package vip.mate.skill.runtime; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import vip.mate.skill.lessons.SkillLessonsService; +import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.service.SkillService; @@ -25,11 +28,27 @@ import java.util.stream.Collectors; */ @Slf4j @Service -@RequiredArgsConstructor public class SkillRuntimeService { private final SkillService skillService; private final SkillPackageResolver packageResolver; + /** + * {@code @Lazy} — SkillLessonsService depends on SkillWorkspaceManager, + * which is constructed early; the lazy proxy avoids a chicken-and-egg + * cycle when the runtime service initializes alongside the skill + * service stack. Using setter-style injection through the + * constructor below. + */ + private final SkillLessonsService lessonsService; + + @Autowired + public SkillRuntimeService(SkillService skillService, + SkillPackageResolver packageResolver, + @Lazy SkillLessonsService lessonsService) { + this.skillService = skillService; + this.packageResolver = packageResolver; + this.lessonsService = lessonsService; + } // 缓存已解析的 active skills(5分钟过期) private final Cache> activeSkillsCache = Caffeine.newBuilder() @@ -232,6 +251,41 @@ public class SkillRuntimeService { sb.append(" |\n"); } + // RFC-090 §11.4.3 + §10.2 Q6 — append per-skill LESSONS.md after + // the catalog so the LLM sees "Available Skills" first, then any + // accumulated lessons attached to each skill that has opted in. + appendLessonsBlock(sb, activeSkills); + return sb.toString(); } + + /** + * Append a "## Lessons learned" block to the prompt enhancement + * with one subsection per active skill that has lessons recorded. + * + *

Skills opt in via {@code self-evolution.lessons_enabled} (default + * true). Skills with no LESSONS.md content contribute nothing — we + * never emit an empty subsection. + */ + private void appendLessonsBlock(StringBuilder sb, List activeSkills) { + if (lessonsService == null || activeSkills == null || activeSkills.isEmpty()) return; + StringBuilder lessons = new StringBuilder(); + for (ResolvedSkill skill : activeSkills) { + SkillManifest manifest = skill.getManifest(); + boolean enabled = manifest == null + || manifest.getSelfEvolution() == null + || manifest.getSelfEvolution().isLessonsEnabled(); + if (!enabled) continue; + String body = lessonsService.readLessonsBody(skill); + if (body == null || body.isBlank()) continue; + lessons.append("\n### ").append(skill.getName()).append("\n"); + lessons.append(body).append("\n"); + } + if (lessons.length() > 0) { + sb.append("\n\n## Lessons learned\n"); + sb.append("Past observations the agent recorded for these skills. "); + sb.append("Treat them as advisory hints — the canonical SKILL.md still wins on conflict.\n"); + sb.append(lessons); + } + } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f22e6f03..f5e3ba8a 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -170,6 +170,10 @@ export const skillApi = { refreshRuntime: () => http.post('/skills/runtime/refresh'), exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`), getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`), + // RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + requirements: (id: string | number) => http.get(`/skills/${id}/requirements`), + getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`), + clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`), } // ==================== Skill Install ==================== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 0803820c..be46f1d7 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1943,10 +1943,15 @@ export default { manifest: 'Manifest', tools: 'Tools', features: 'Features', + lessons: 'Lessons', noManifest: 'This skill does not declare a v3 manifest. Legacy fields apply.', noTools: 'No tools advertised by active features.', noFeatures: 'No features[] matrix declared. The skill is treated as a single default feature.', + noLessons: 'No lessons recorded yet. The skill writes here after each call when self-evolution is enabled.', toolsHint: 'These tool names are merged into the LLM allowed-tool list when this skill is bound to an agent. Tools owned by SETUP_NEEDED features stay hidden.', + lessonsHint: 'Per-skill LESSONS.md. Auto-injected after SKILL.md body when this skill is loaded.', + clearLessons: 'Clear all', + clearLessonsConfirm: 'Delete all recorded lessons for this skill? This cannot be undone.', }, runtime: { disabled: 'Disabled', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 3071a3f5..698dccc7 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1945,10 +1945,15 @@ export default { manifest: 'Manifest', tools: '工具', features: '特性', + lessons: 'Lessons', noManifest: '该技能未声明 v3 manifest,使用旧字段。', noTools: '当前激活特性未暴露任何工具。', noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。', + noLessons: '尚未记录任何 lesson。开启 self-evolution 后,每次调用结束 skill 可向此处写入经验。', toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。', + lessonsHint: '该 skill 的 LESSONS.md 内容。下次加载时自动注入到 SKILL.md 正文之后。', + clearLessons: '全部清空', + clearLessonsConfirm: '删除该 skill 的全部 lessons?操作不可撤销。', }, runtime: { disabled: '已停用', diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index 17e0c300..422a6f61 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -255,6 +255,9 @@ {{ t('skills.detail.features') }} {{ detailFeaturesCount }} +

@@ -297,6 +300,18 @@
+ +
+
+

{{ t('skills.detail.lessonsHint') }}

+ +
+

{{ t('common.loading') }}

+

{{ t('skills.detail.noLessons') }}

+
{{ detailLessonsRaw }}
+
@@ -429,7 +444,9 @@ const rescanning = ref>({}) /** RFC-090 Phase 3 — detail drawer state. */ const detailDrawerVisible = ref(false) const detailSkill = ref(null) -const detailTab = ref<'manifest' | 'tools' | 'features'>('manifest') +const detailTab = ref<'manifest' | 'tools' | 'features' | 'lessons'>('manifest') +const detailLessonsRaw = ref('') +const detailLessonsLoading = ref(false) const detailRuntime = computed(() => detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null, @@ -451,9 +468,45 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length) function openDetailDrawer(skill: Skill) { detailSkill.value = skill detailTab.value = 'manifest' + detailLessonsRaw.value = '' detailDrawerVisible.value = true } +async function loadLessons() { + if (!detailSkill.value) return + detailLessonsLoading.value = true + try { + const res: any = await skillApi.getLessons(detailSkill.value.id) + detailLessonsRaw.value = res?.data?.raw || '' + } catch (e: any) { + detailLessonsRaw.value = '' + console.warn('[SkillMarket] failed to load lessons', e) + } finally { + detailLessonsLoading.value = false + } +} + +async function clearLessons() { + if (!detailSkill.value) return + try { + await ElMessageBox.confirm(t('skills.detail.clearLessonsConfirm'), t('skills.messages.deleteTitle'), { type: 'warning' }) + } catch { return } + try { + await skillApi.clearLessons(detailSkill.value.id) + detailLessonsRaw.value = '' + } catch (e: any) { + ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.messages.deleteFailed')) + } +} + +watch(detailTab, (tab) => { + // Lazy load LESSONS.md only when the user clicks into that tab; we + // don't want every drawer open to fire an extra request. + if (tab === 'lessons' && detailDrawerVisible.value && !detailLessonsRaw.value && !detailLessonsLoading.value) { + loadLessons() + } +}) + const categoryTabs = computed(() => [ { label: t('skills.tabs.all'), value: 'all', icon: '🗂️' }, { label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' }, @@ -1225,4 +1278,9 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); } .detail-meta-key { color: var(--mc-text-tertiary); margin-right: 4px; } .detail-feature-tag { padding: 2px 6px; background: var(--mc-bg-elevated); border-radius: 4px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; color: var(--mc-text-primary); } .detail-feature-fallback { font-size: 11px; color: var(--mc-primary-hover); margin-top: 6px; font-style: italic; } + +.lessons-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; } +.lessons-clear-btn { padding: 6px 12px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; font-size: 12px; } +.lessons-clear-btn:hover:not(:disabled) { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); } +.lessons-clear-btn:disabled { opacity: 0.5; cursor: not-allowed; }