feat(skill): per-skill LESSONS.md + self-evolution v1

This commit is contained in:
matevip 2026-05-01 09:49:07 +08:00
parent d927521d51
commit 359600c77f
10 changed files with 698 additions and 3 deletions

View File

@ -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.
*
* <p>{@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.
*
* <p>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);
}
}

View File

@ -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<Map<String, Object>> 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<Map<String, Object>> 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 = "从对话历史合成 SkillRFC-023")

View File

@ -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.
*
* <p>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.
*
* <p>Why a dedicated file rather than {@code MEMORY.md}:
* <ul>
* <li>Lessons are scoped to <i>this</i> skill the agent must not
* leak skill-specific tactics into another skill's context.</li>
* <li>Agents share canonical memory, but a single skill may belong
* to multiple agents; each agent's MEMORY.md is global.</li>
* <li>Per §14.3, mixing lessons into {@code MemoryWriteEvent} would
* inflate the SOUL summarizer's recompute counter.</li>
* </ul>
*
* <p>Storage format (markdown, append-only, FIFO truncated):
* <pre>
* # 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...)
* ...
* </pre>
*/
@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}.
*
* <p>Side effects:
* <ol>
* <li>Creates the file if it doesn't exist (with the canonical
* header).</li>
* <li>Appends a new {@code ## yyyy-MM-dd HH:mm (conversation: id)}
* section with the lesson body.</li>
* <li>If total entries exceed {@code maxEntries}, drops the oldest
* sections (FIFO truncation).</li>
* <li>Publishes {@link SkillLessonWrittenEvent} on success.</li>
* </ol>
*
* @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<String> 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<String> 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## "));
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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
) {}

View File

@ -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 skills5分钟过期
private final Cache<String, List<ResolvedSkill>> 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.
*
* <p>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<ResolvedSkill> 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);
}
}
}

View File

@ -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 ====================

View File

@ -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',

View File

@ -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: '已停用',

View File

@ -255,6 +255,9 @@
{{ t('skills.detail.features') }}
<span v-if="detailFeaturesCount > 0" class="tab-count">{{ detailFeaturesCount }}</span>
</button>
<button class="detail-tab" :class="{ active: detailTab === 'lessons' }" @click="detailTab = 'lessons'">
{{ t('skills.detail.lessons') }}
</button>
</div>
<!-- Manifest tab -->
<div v-if="detailTab === 'manifest'" class="detail-section">
@ -297,6 +300,18 @@
</li>
</ul>
</div>
<!-- RFC-090 §11.4 Lessons tab -->
<div v-if="detailTab === 'lessons'" class="detail-section">
<div class="lessons-header">
<p class="detail-hint">{{ t('skills.detail.lessonsHint') }}</p>
<button class="lessons-clear-btn" :disabled="!detailLessonsRaw" @click="clearLessons">
{{ t('skills.detail.clearLessons') }}
</button>
</div>
<p v-if="detailLessonsLoading" class="detail-empty">{{ t('common.loading') }}</p>
<p v-else-if="!detailLessonsRaw" class="detail-empty">{{ t('skills.detail.noLessons') }}</p>
<pre v-else class="detail-pre">{{ detailLessonsRaw }}</pre>
</div>
</div>
</el-drawer>
@ -429,7 +444,9 @@ const rescanning = ref<Record<string, boolean>>({})
/** RFC-090 Phase 3 — detail drawer state. */
const detailDrawerVisible = ref(false)
const detailSkill = ref<Skill | null>(null)
const detailTab = ref<'manifest' | 'tools' | 'features'>('manifest')
const detailTab = ref<'manifest' | 'tools' | 'features' | 'lessons'>('manifest')
const detailLessonsRaw = ref<string>('')
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; }
</style>