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}:
+ *
+ * Lessons are scoped to this skill — the agent must not
+ * leak skill-specific tactics into another skill's context.
+ * Agents share canonical memory, but a single skill may belong
+ * to multiple agents; each agent's MEMORY.md is global.
+ * Per §14.3, mixing lessons into {@code MemoryWriteEvent} would
+ * inflate the SOUL summarizer's recompute counter.
+ *
+ *
+ * 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:
+ *
+ * Creates the file if it doesn't exist (with the canonical
+ * header).
+ * Appends a new {@code ## yyyy-MM-dd HH:mm (conversation: id)}
+ * section with the lesson body.
+ * If total entries exceed {@code maxEntries}, drops the oldest
+ * sections (FIFO truncation).
+ * Publishes {@link SkillLessonWrittenEvent} on success.
+ *
+ *
+ * @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 }}
+
+ {{ t('skills.detail.lessons') }}
+
@@ -297,6 +300,18 @@
+
+
+
+
{{ 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; }