diff --git a/mateclaw-server/src/main/java/vip/mate/memory/archive/MemoryArchiveService.java b/mateclaw-server/src/main/java/vip/mate/memory/archive/MemoryArchiveService.java new file mode 100644 index 00000000..9ab65b85 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/archive/MemoryArchiveService.java @@ -0,0 +1,131 @@ +package vip.mate.memory.archive; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.memory.MemoryProperties; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Monthly archive service for DREAMS.md. + *

+ * Moves entries older than archiveKeepDays to monthly archive files + * at memory/dreams/YYYY-MM.md. Idempotent: already-archived entries + * are not moved again. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemoryArchiveService { + + private final WorkspaceFileService workspaceFileService; + private final MemoryProperties properties; + + private static final Pattern DIARY_HEADER = Pattern.compile( + "^## (\\d{4}-\\d{2}-\\d{2}) \\d{2}:\\d{2}.*$"); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * Archive old entries from DREAMS.md to monthly files. + * First line checks archiveEnabled flag. + */ + public void archiveOldDreams(Long agentId) { + if (!properties.getDream().isArchiveEnabled()) return; + + WorkspaceFileEntity dreamsFile = workspaceFileService.getFile(agentId, "DREAMS.md"); + if (dreamsFile == null || dreamsFile.getContent() == null || dreamsFile.getContent().isBlank()) { + return; + } + + String content = dreamsFile.getContent(); + LocalDate cutoff = LocalDate.now().minusDays(properties.getDream().getArchiveKeepDays()); + + // Split into sections by ## header + String[] lines = content.split("\n"); + StringBuilder kept = new StringBuilder(); + Map archives = new LinkedHashMap<>(); // YYYY-MM -> content + + StringBuilder currentSection = new StringBuilder(); + String currentDate = null; + boolean inHeader = true; // first lines before any ## section + + for (String line : lines) { + Matcher m = DIARY_HEADER.matcher(line); + if (m.matches()) { + // Flush previous section + flushSection(currentDate, currentSection, cutoff, kept, archives); + currentDate = m.group(1); + currentSection = new StringBuilder(); + currentSection.append(line).append("\n"); + inHeader = false; + } else if (inHeader) { + kept.append(line).append("\n"); + } else { + currentSection.append(line).append("\n"); + } + } + // Flush last section + flushSection(currentDate, currentSection, cutoff, kept, archives); + + if (archives.isEmpty()) { + log.debug("[Memory] No old dream entries to archive for agent={}", agentId); + return; + } + + // Write archive files + for (Map.Entry entry : archives.entrySet()) { + String archiveFilename = "memory/dreams/" + entry.getKey() + ".md"; + String existing = readSafe(agentId, archiveFilename); + String archiveContent = existing.isBlank() + ? "# Dreaming Archive " + entry.getKey() + "\n\n" + entry.getValue() + : existing + "\n" + entry.getValue(); + workspaceFileService.saveFile(agentId, archiveFilename, archiveContent); + } + + // Update DREAMS.md with only kept entries + String newContent = kept.toString().trim(); + if (newContent.isEmpty()) { + newContent = "# Dreaming 整合日记\n\n> All entries archived."; + } + workspaceFileService.saveFile(agentId, "DREAMS.md", newContent); + + int archivedMonths = archives.size(); + log.info("[Memory] Archived dream entries to {} monthly files for agent={}", archivedMonths, agentId); + } + + private void flushSection(String dateStr, StringBuilder section, LocalDate cutoff, + StringBuilder kept, Map archives) { + if (dateStr == null || section.length() == 0) return; + try { + LocalDate date = LocalDate.parse(dateStr, DATE_FMT); + if (date.isBefore(cutoff)) { + String monthKey = dateStr.substring(0, 7); // YYYY-MM + archives.computeIfAbsent(monthKey, k -> new StringBuilder()).append(section); + } else { + kept.append(section); + } + } catch (Exception e) { + // Unparseable date: keep it + kept.append(section); + } + } + + private String readSafe(Long agentId, String filename) { + try { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + return file != null && file.getContent() != null ? file.getContent() : ""; + } catch (Exception e) { + return ""; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java index f8491d36..4abbfcfe 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java @@ -7,9 +7,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; -import vip.mate.memory.service.MemoryEmergenceService; -import vip.mate.memory.service.MemoryRecallService; -import vip.mate.memory.service.MemorySummarizationService; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.service.*; import vip.mate.memory.scheduler.DreamingScheduler; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; @@ -35,22 +34,44 @@ public class MemoryController { private final MemoryEmergenceService emergenceService; private final MemorySummarizationService summarizationService; private final MemoryRecallService recallService; + private final MemoryProperties memoryProperties; private final DreamingScheduler dreamingScheduler; private final WorkspaceFileService workspaceFileService; - @Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md)") + @Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md,NIGHTLY 模式)") @PostMapping("/{agentId}/emergence") @RequireWorkspaceRole("member") - public R> triggerEmergence(@PathVariable Long agentId) { + public R triggerEmergence(@PathVariable Long agentId) { try { - emergenceService.consolidate(agentId); - return R.ok(Map.of("status", "completed")); + DreamReport report = emergenceService.consolidate(agentId, DreamMode.NIGHTLY, null); + return R.ok(report); } catch (Exception e) { log.error("[Memory] Manual emergence failed for agent={}: {}", agentId, e.getMessage(), e); return R.fail("记忆整合失败: " + e.getMessage()); } } + @Operation(summary = "Focused Dream — 围绕指定主题触发记忆整合") + @PostMapping("/{agentId}/dreaming/focused") + @RequireWorkspaceRole("member") + public R triggerFocusedDream(@PathVariable Long agentId, + @RequestBody Map body) { + if (!memoryProperties.getDream().isFocusedEnabled()) { + return R.fail(410, "Focused dream is disabled"); + } + String topic = body != null ? body.get("topic") : null; + if (topic == null || topic.isBlank()) { + return R.fail("topic is required"); + } + try { + DreamReport report = emergenceService.consolidate(agentId, DreamMode.FOCUSED, topic); + return R.ok(report); + } catch (Exception e) { + log.error("[Memory] Focused dream failed for agent={}: {}", agentId, e.getMessage(), e); + return R.fail("Focused dream failed: " + e.getMessage()); + } + } + @Operation(summary = "手动触发对话记忆提取") @PostMapping("/{agentId}/summarize/{conversationId}") @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java new file mode 100644 index 00000000..b912bd87 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java @@ -0,0 +1,62 @@ +package vip.mate.memory.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Dream report entity — persists each dream consolidation run. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_dream_report") +public class DreamReportEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long agentId; + + /** NIGHTLY | FOCUSED */ + private String mode; + + /** Topic hint for FOCUSED mode; null for NIGHTLY */ + private String topic; + + /** cron | user | api */ + private String triggerSource; + + /** userId or "system" */ + private String triggeredBy; + + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; + + private Integer candidateCount; + + private Integer promotedCount; + + private Integer rejectedCount; + + /** Diff between old and new MEMORY.md */ + private String memoryDiff; + + /** LLM explanation (first 500 chars) */ + private String llmReason; + + /** SUCCESS | FAILED | SKIPPED */ + private String status; + + private String errorMessage; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java index 97f335f7..3902e95e 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java @@ -50,6 +50,12 @@ public class MemoryRecallEntity { /** 是否已提升到 MEMORY.md */ private Boolean promoted; + /** Times this candidate was reviewed but not promoted (Dream v2, Phase 1 write-only) */ + private Integer reviewCount; + + /** Last time this candidate was reviewed during a dream run */ + private LocalDateTime lastReviewedAt; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java new file mode 100644 index 00000000..aad533c4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java @@ -0,0 +1,14 @@ +package vip.mate.memory.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.memory.model.DreamReportEntity; + +/** + * Mapper for mate_dream_report table. + * + * @author MateClaw Team + */ +@Mapper +public interface DreamReportMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java index c10ba4f8..58a34fb1 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java @@ -52,7 +52,7 @@ public class DreamingScheduler { continue; } try { - emergenceService.consolidate(agent.getId()); + emergenceService.consolidate(agent.getId(), vip.mate.memory.service.DreamMode.NIGHTLY, null); success++; } catch (Exception e) { failed++; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java new file mode 100644 index 00000000..06f8ac0b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java @@ -0,0 +1,11 @@ +package vip.mate.memory.service; + +/** + * Dream consolidation modes. Phase 1: NIGHTLY + FOCUSED only. + * + * @author MateClaw Team + */ +public enum DreamMode { + NIGHTLY, + FOCUSED +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java new file mode 100644 index 00000000..38bac953 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java @@ -0,0 +1,29 @@ +package vip.mate.memory.service; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Structured dream consolidation result returned by consolidate(). + * + * @author MateClaw Team + */ +public record DreamReport( + Long id, + Long agentId, + DreamMode mode, + String topic, + String triggerSource, + String triggeredBy, + LocalDateTime startedAt, + LocalDateTime finishedAt, + int candidateCount, + int promotedCount, + int rejectedCount, + String memoryDiff, + String llmReason, + DreamStatus status, + String errorMessage, + List promoted, + List rejected +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java new file mode 100644 index 00000000..5196507b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java @@ -0,0 +1,12 @@ +package vip.mate.memory.service; + +/** + * Dream consolidation result status. + * + * @author MateClaw Team + */ +public enum DreamStatus { + SUCCESS, + FAILED, + SKIPPED +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java index aff44dcf..13faf2b7 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java @@ -15,19 +15,21 @@ import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.model.DreamReportEntity; import vip.mate.memory.model.MemoryRecallEntity; +import vip.mate.memory.repository.DreamReportMapper; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; -import java.util.Comparator; -import java.util.List; +import java.time.LocalDateTime; +import java.util.*; import java.util.stream.Collectors; /** - * 记忆整合服务 + * Memory emergence (dream) service. *

- * 读取近 N 天的 daily notes,提炼反复出现的模式和重要信息, - * 合并到 MEMORY.md 中。 + * Reads daily notes + scored recall candidates, invokes LLM to consolidate + * recurring patterns into MEMORY.md, and produces a structured DreamReport. * * @author MateClaw Team */ @@ -42,19 +44,34 @@ public class MemoryEmergenceService { private final MemoryProperties properties; private final ObjectMapper objectMapper; private final MemoryRecallService recallService; + private final DreamReportMapper dreamReportMapper; + private final vip.mate.memory.archive.MemoryArchiveService archiveService; /** - * 执行记忆整合:将 daily notes 中的重复模式提炼到 MEMORY.md + * Legacy signature — delegates to NIGHTLY mode for backward compatibility. + */ + public DreamReport consolidate(Long agentId) { + return consolidate(agentId, DreamMode.NIGHTLY, null); + } + + /** + * Execute memory consolidation with the specified mode and optional topic. * * @param agentId Agent ID + * @param mode NIGHTLY or FOCUSED + * @param topic topic hint for FOCUSED mode (null for NIGHTLY) + * @return structured DreamReport (never null) */ - public void consolidate(Long agentId) { + public DreamReport consolidate(Long agentId, DreamMode mode, String topic) { + LocalDateTime startedAt = LocalDateTime.now(); + String triggerSource = mode == DreamMode.NIGHTLY ? "cron" : "user"; + if (!properties.isEmergenceEnabled()) { log.debug("[Memory] Emergence is disabled, skipping for agent={}", agentId); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "emergence disabled"); } - // 1. 列出所有 memory/*.md 文件 + // 1. Load daily notes List allFiles = workspaceFileService.listFiles(agentId); List dailyFilenames = allFiles.stream() .map(WorkspaceFileEntity::getFilename) @@ -65,13 +82,11 @@ public class MemoryEmergenceService { if (dailyFilenames.isEmpty()) { log.info("[Memory] No daily notes found for agent={}, skipping emergence", agentId); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "no daily notes"); } - // 2. 批量读取 daily notes 内容(避免 N+1 查询) StringBuilder dailyNotesBuilder = new StringBuilder(); for (String filename : dailyFilenames) { - // TODO: 未来可优化为 IN 批量查询,当前 listFiles() 会清除 content 字段 WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); if (file != null && file.getContent() != null && !file.getContent().isBlank()) { dailyNotesBuilder.append("### ").append(filename).append("\n"); @@ -82,41 +97,28 @@ public class MemoryEmergenceService { if (dailyNotes.isEmpty()) { log.info("[Memory] All daily notes are empty for agent={}, skipping emergence", agentId); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "all daily notes empty"); } - // 3. 读取现有 MEMORY.md - String memoryContent = readFileContentSafe(agentId, "MEMORY.md"); + // 2. Read existing MEMORY.md (for diff later) + String oldMemoryContent = readFileContentSafe(agentId, "MEMORY.md"); - // 4. 计算召回评分(必须在 resetDailyCounts 之前,否则 velocity 信号被清零) + // 3. Score candidates (must happen before resetDailyCounts) List scoredCandidates = recallService.computeScores(agentId); boolean hasScoredCandidates = !scoredCandidates.isEmpty(); - // 5. 评分快照完成后再重置 dailyCount,为下一轮积累 + // 4. Reset daily counts for next accumulation cycle recallService.resetDailyCounts(agentId); + // 5. Build prompt based on mode String systemPrompt = PromptLoader.loadPrompt("memory/emergence-system"); - String userPrompt; + String userPrompt = buildUserPrompt(mode, topic, oldMemoryContent, scoredCandidates, + hasScoredCandidates, dailyNotes); - if (hasScoredCandidates) { - // 使用评分增强的 prompt - String candidatesText = formatScoredCandidates(scoredCandidates); - String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user"); - userPrompt = userTemplate - .replace("{memory}", memoryContent) - .replace("{scored_candidates}", candidatesText) - .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) - .replace("{daily_notes}", dailyNotes); - log.info("[Memory] Emergence with {} scored candidates for agent={}", scoredCandidates.size(), agentId); - } else { - // 冷启动:回退到原有纯 LLM 逻辑 - String userTemplate = PromptLoader.loadPrompt("memory/emergence-user"); - userPrompt = userTemplate - .replace("{memory}", memoryContent) - .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) - .replace("{daily_notes}", dailyNotes); - } + log.info("[Memory] Emergence {} with {} candidates for agent={}, topic={}", + mode, scoredCandidates.size(), agentId, topic); + // 6. Call LLM String llmResponse; try { ChatModel chatModel = buildChatModel(); @@ -128,69 +130,146 @@ public class MemoryEmergenceService { llmResponse = response.getResult().getOutput().getText(); } catch (Exception e) { log.warn("[Memory] Emergence LLM call failed for agent={}: {}", agentId, e.getMessage()); - return; + return buildFailedReport(agentId, mode, topic, triggerSource, startedAt, + scoredCandidates.size(), e.getMessage()); } - // 5. 解析并应用 + // 7. Parse and apply try { JsonNode root = parseJsonResponse(llmResponse); if (root == null || !root.path("should_update").asBoolean(false)) { String reason = root != null ? root.path("reason").asText("") : "parse failed"; log.info("[Memory] No emergence update needed for agent={}: {}", agentId, reason); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, reason); } JsonNode memoryNode = root.path("memory_content"); - if (!memoryNode.isNull() && memoryNode.isTextual()) { - String newContent = memoryNode.asText().trim(); - if (!newContent.isEmpty()) { - workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); - String reason = root.path("reason").asText(""); - log.info("[Memory] Emergence completed for agent={}: {}", agentId, reason); + if (memoryNode.isNull() || !memoryNode.isTextual()) { + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "no memory_content in response"); + } - // 逐候选检查:只有内容被 LLM 实际采纳(出现在新 MEMORY.md 中)的才标记为已提升 - if (hasScoredCandidates) { - List promotedIds = scoredCandidates.stream() - .filter(c -> candidateAdoptedInMemory(c, newContent)) - .map(MemoryRecallEntity::getId) - .collect(Collectors.toList()); - if (!promotedIds.isEmpty()) { - recallService.markPromoted(promotedIds); - } - log.info("[Memory] Promoted {}/{} recall candidates for agent={}", - promotedIds.size(), scoredCandidates.size(), agentId); + String newContent = memoryNode.asText().trim(); + if (newContent.isEmpty()) { + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content"); + } - // 写入 DREAMS.md 整合日记 - appendDreamDiary(agentId, scoredCandidates, promotedIds); + workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); + String llmReason = root.path("reason").asText(""); + log.info("[Memory] Emergence completed for agent={}: {}", agentId, llmReason); + + // Determine promoted vs rejected candidates + List promotedEntries = new ArrayList<>(); + List rejectedEntries = new ArrayList<>(); + + if (hasScoredCandidates) { + Set promotedIds = new HashSet<>(); + for (MemoryRecallEntity c : scoredCandidates) { + if (candidateAdoptedInMemory(c, newContent)) { + promotedIds.add(c.getId()); + promotedEntries.add(new PromotedEntry( + c.getId(), c.getFilename(), c.getSnippetPreview(), c.getScore())); + } else { + // Increment review_count for rejected candidates + int newReviewCount = (c.getReviewCount() != null ? c.getReviewCount() : 0) + 1; + rejectedEntries.add(new RejectedEntry( + c.getId(), c.getFilename(), c.getSnippetPreview(), + c.getScore(), newReviewCount)); } } + + if (!promotedIds.isEmpty()) { + recallService.markPromoted(new ArrayList<>(promotedIds)); + } + // Update review_count / last_reviewed_at for rejected candidates + recallService.incrementReviewCounts( + rejectedEntries.stream().map(RejectedEntry::recallId).toList()); + + log.info("[Memory] Promoted {}/{} recall candidates for agent={}", + promotedIds.size(), scoredCandidates.size(), agentId); + + // Append dream diary + appendDreamDiary(agentId, scoredCandidates, new ArrayList<>(promotedIds), mode, topic); } + + // Compute diff + String memoryDiff = computeDiff(oldMemoryContent, newContent); + + // Build and persist report + DreamReport report = buildSuccessReport(agentId, mode, topic, triggerSource, startedAt, + scoredCandidates.size(), promotedEntries, rejectedEntries, memoryDiff, + truncate(llmReason, 500)); + persistReport(report); + return report; + } catch (Exception e) { log.warn("[Memory] Failed to parse/apply emergence result for agent={}: {}", agentId, e.getMessage()); + return buildFailedReport(agentId, mode, topic, triggerSource, startedAt, + scoredCandidates.size(), e.getMessage()); } } /** - * 将本轮 dreaming 结果追加到 DREAMS.md 整合日记 + * Build user prompt based on dream mode. */ - private void appendDreamDiary(Long agentId, List allCandidates, List promotedIds) { + private String buildUserPrompt(DreamMode mode, String topic, String memoryContent, + List scoredCandidates, + boolean hasScoredCandidates, String dailyNotes) { + if (mode == DreamMode.FOCUSED && topic != null && !topic.isBlank()) { + // FOCUSED mode: use topic-biased prompt + String candidatesText = hasScoredCandidates ? formatScoredCandidates(scoredCandidates) : "(no scored candidates)"; + String userTemplate = PromptLoader.loadPrompt("memory/emergence-focused-user"); + return userTemplate + .replace("{memory}", memoryContent) + .replace("{topic}", topic) + .replace("{scored_candidates}", candidatesText) + .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) + .replace("{daily_notes}", dailyNotes); + } + + // NIGHTLY mode: existing scored or plain prompt + if (hasScoredCandidates) { + String candidatesText = formatScoredCandidates(scoredCandidates); + String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user"); + return userTemplate + .replace("{memory}", memoryContent) + .replace("{scored_candidates}", candidatesText) + .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) + .replace("{daily_notes}", dailyNotes); + } else { + String userTemplate = PromptLoader.loadPrompt("memory/emergence-user"); + return userTemplate + .replace("{memory}", memoryContent) + .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) + .replace("{daily_notes}", dailyNotes); + } + } + + /** + * Append dream diary to DREAMS.md. + */ + void appendDreamDiary(Long agentId, List allCandidates, + List promotedIds, DreamMode mode, String topic) { try { - java.util.Set promotedSet = new java.util.HashSet<>(promotedIds); + Set promotedSet = new HashSet<>(promotedIds); List promoted = allCandidates.stream() .filter(c -> promotedSet.contains(c.getId())) - .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) .toList(); List kept = allCandidates.stream() .filter(c -> !promotedSet.contains(c.getId())) - .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) .toList(); - String timestamp = java.time.LocalDateTime.now() + String timestamp = LocalDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); StringBuilder diary = new StringBuilder(); - diary.append("## ").append(timestamp).append(" Dreaming\n\n"); + diary.append("## ").append(timestamp).append(" Dreaming"); + if (mode == DreamMode.FOCUSED && topic != null) { + diary.append(" [FOCUSED: ").append(topic).append("]"); + } + diary.append("\n\n"); diary.append(String.format("**评分候选**: %d 条(阈值 %.1f)\n", allCandidates.size(), properties.getEmergenceScoreThreshold())); diary.append(String.format("**实际整合**: %d 条\n\n", promoted.size())); @@ -213,13 +292,13 @@ public class MemoryEmergenceService { diary.append("\n"); } - // 读取现有 DREAMS.md,追加新日记 + // Read existing DREAMS.md, append new diary String existing = readFileContentSafe(agentId, "DREAMS.md"); String newContent = existing.isBlank() ? "# Dreaming 整合日记\n\n" + diary : existing + "\n" + diary; - // 防止无限膨胀:超过 20KB 时截断,只保留最近的内容 + // Fallback hard truncation at 20KB (Phase 0 behavior preserved when archive flag is off) if (newContent.length() > 20_000) { int cutPoint = newContent.length() - 16_000; int safePoint = newContent.indexOf("\n## ", cutPoint); @@ -227,7 +306,6 @@ public class MemoryEmergenceService { newContent = "# Dreaming 整合日记\n\n> 早期记录已归档\n\n" + newContent.substring(safePoint + 1); } else { - // 没找到 ## 标记,硬截断保留最后 16KB newContent = "# Dreaming 整合日记\n\n> 早期记录已归档\n\n" + newContent.substring(cutPoint); } @@ -235,11 +313,80 @@ public class MemoryEmergenceService { workspaceFileService.saveFile(agentId, "DREAMS.md", newContent); log.info("[Memory] Dream diary appended for agent={}", agentId); + + // Archive old entries or fall back to 20KB truncation + if (properties.getDream().isArchiveEnabled()) { + archiveService.archiveOldDreams(agentId); + } } catch (Exception e) { log.warn("[Memory] Failed to write dream diary for agent={}: {}", agentId, e.getMessage()); } } + // ==================== Report builders ==================== + + private DreamReport buildSuccessReport(Long agentId, DreamMode mode, String topic, + String triggerSource, LocalDateTime startedAt, + int candidateCount, + List promoted, + List rejected, + String memoryDiff, String llmReason) { + return new DreamReport(null, agentId, mode, topic, triggerSource, "system", + startedAt, LocalDateTime.now(), candidateCount, + promoted.size(), rejected.size(), memoryDiff, llmReason, + DreamStatus.SUCCESS, null, promoted, rejected); + } + + private DreamReport buildSkippedReport(Long agentId, DreamMode mode, String topic, + String triggerSource, LocalDateTime startedAt, + String reason) { + DreamReport report = new DreamReport(null, agentId, mode, topic, triggerSource, "system", + startedAt, LocalDateTime.now(), 0, 0, 0, null, reason, + DreamStatus.SKIPPED, null, List.of(), List.of()); + persistReport(report); + return report; + } + + private DreamReport buildFailedReport(Long agentId, DreamMode mode, String topic, + String triggerSource, LocalDateTime startedAt, + int candidateCount, String errorMessage) { + DreamReport report = new DreamReport(null, agentId, mode, topic, triggerSource, "system", + startedAt, LocalDateTime.now(), candidateCount, 0, 0, null, null, + DreamStatus.FAILED, errorMessage, List.of(), List.of()); + persistReport(report); + return report; + } + + private void persistReport(DreamReport report) { + try { + DreamReportEntity entity = new DreamReportEntity(); + entity.setAgentId(report.agentId()); + entity.setMode(report.mode().name()); + entity.setTopic(report.topic()); + entity.setTriggerSource(report.triggerSource()); + entity.setTriggeredBy(report.triggeredBy()); + entity.setStartedAt(report.startedAt()); + entity.setFinishedAt(report.finishedAt()); + entity.setCandidateCount(report.candidateCount()); + entity.setPromotedCount(report.promotedCount()); + entity.setRejectedCount(report.rejectedCount()); + entity.setMemoryDiff(report.memoryDiff()); + entity.setLlmReason(report.llmReason()); + entity.setStatus(report.status().name()); + entity.setErrorMessage(report.errorMessage()); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateTime(LocalDateTime.now()); + entity.setDeleted(0); + dreamReportMapper.insert(entity); + log.debug("[Memory] DreamReport persisted: agent={}, mode={}, status={}", + report.agentId(), report.mode(), report.status()); + } catch (Exception e) { + log.warn("[Memory] Failed to persist DreamReport for agent={}: {}", report.agentId(), e.getMessage()); + } + } + + // ==================== Helpers ==================== + private String formatScoredCandidates(List candidates) { StringBuilder sb = new StringBuilder(); for (MemoryRecallEntity entry : candidates) { @@ -253,16 +400,11 @@ public class MemoryEmergenceService { return sb.toString().trim(); } - /** - * 判断候选片段是否被 LLM 实际采纳到新 MEMORY.md 中。 - * 通过检查片段预览中的关键短语(取前 3 个非空行的前 20 字符)是否出现在新内容中。 - */ private boolean candidateAdoptedInMemory(MemoryRecallEntity candidate, String newMemoryContent) { String preview = candidate.getSnippetPreview(); if (preview == null || preview.isBlank() || newMemoryContent == null) { return false; } - // 从 snippet 提取关键短语进行匹配 String[] lines = preview.split("\n"); int matched = 0; int checked = 0; @@ -271,17 +413,29 @@ public class MemoryEmergenceService { if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; if (checked >= 3) break; checked++; - // 取行的核心内容(去掉 markdown 标记),检查是否出现在新 MEMORY.md 中 String key = trimmed.replaceAll("^[-*>]+\\s*", ""); if (key.length() > 20) key = key.substring(0, 20); if (key.length() >= 5 && newMemoryContent.contains(key)) { matched++; } } - // 至少有 1 个关键短语命中才算采纳 return matched > 0; } + private String computeDiff(String oldContent, String newContent) { + if (oldContent == null || oldContent.isBlank()) return "(new file)"; + if (oldContent.equals(newContent)) return "(no change)"; + // Simple line-count diff for Phase 1 + int oldLines = oldContent.split("\n").length; + int newLines = newContent.split("\n").length; + return String.format("-%d/+%d lines", oldLines, newLines); + } + + private String truncate(String s, int maxLen) { + if (s == null) return null; + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } + private ChatModel buildChatModel() { ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); return agentGraphBuilder.buildRuntimeChatModel(defaultModel); @@ -309,7 +463,7 @@ public class MemoryEmergenceService { } } - private String readFileContentSafe(Long agentId, String filename) { + String readFileContentSafe(Long agentId, String filename) { try { WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); return file != null && file.getContent() != null ? file.getContent() : ""; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java index 39e4ea70..6cf287f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java @@ -227,7 +227,7 @@ public class MemoryRecallService { } /** - * 标记候选为已提升 + * Mark candidates as promoted to MEMORY.md. */ public void markPromoted(List ids) { if (ids == null || ids.isEmpty()) return; @@ -237,6 +237,21 @@ public class MemoryRecallService { .set(MemoryRecallEntity::getPromoted, true)); } + /** + * Increment review_count and set last_reviewed_at for rejected candidates. + * Phase 1: write-only; filtering by review_count is deferred to Phase 2. + */ + public void incrementReviewCounts(List ids) { + if (ids == null || ids.isEmpty()) return; + for (Long id : ids) { + recallMapper.update(null, + new LambdaUpdateWrapper() + .eq(MemoryRecallEntity::getId, id) + .setSql("review_count = COALESCE(review_count, 0) + 1") + .set(MemoryRecallEntity::getLastReviewedAt, java.time.LocalDateTime.now())); + } + } + // ==================== 查询方法(供 API 使用) ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java b/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java new file mode 100644 index 00000000..a40c1b54 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java @@ -0,0 +1,13 @@ +package vip.mate.memory.service; + +/** + * A candidate that was adopted into MEMORY.md during a dream. + * + * @author MateClaw Team + */ +public record PromotedEntry( + Long recallId, + String filename, + String snippetPreview, + double score +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java b/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java new file mode 100644 index 00000000..ac7b1bb4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java @@ -0,0 +1,14 @@ +package vip.mate.memory.service; + +/** + * A candidate that was scored but not adopted into MEMORY.md during a dream. + * + * @author MateClaw Team + */ +public record RejectedEntry( + Long recallId, + String filename, + String snippetPreview, + double score, + int reviewCount +) {} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V26__dream_report.sql b/mateclaw-server/src/main/resources/db/migration/h2/V26__dream_report.sql new file mode 100644 index 00000000..9fb567b3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V26__dream_report.sql @@ -0,0 +1,24 @@ +-- Dream v2: structured dream report (rfc-035 §4.4) +CREATE TABLE IF NOT EXISTS mate_dream_report ( + id BIGINT PRIMARY KEY, + agent_id BIGINT NOT NULL, + mode VARCHAR(32) NOT NULL, + topic VARCHAR(256), + trigger_source VARCHAR(32) NOT NULL, + triggered_by VARCHAR(64), + started_at DATETIME NOT NULL, + finished_at DATETIME NOT NULL, + candidate_count INT NOT NULL, + promoted_count INT NOT NULL, + rejected_count INT NOT NULL, + memory_diff TEXT, + llm_reason TEXT, + status VARCHAR(16) NOT NULL, + error_message TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_dream_agent_time ON mate_dream_report(agent_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_dream_agent_mode ON mate_dream_report(agent_id, mode, started_at DESC); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V27__memory_recall_review_fields.sql b/mateclaw-server/src/main/resources/db/migration/h2/V27__memory_recall_review_fields.sql new file mode 100644 index 00000000..d25ccd9d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V27__memory_recall_review_fields.sql @@ -0,0 +1,4 @@ +-- Dream v2: candidate state machine fields (rfc-035 §4.1.4) +-- Phase 1 writes values only; filtering enabled in Phase 2. +ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS review_count INT DEFAULT 0; +ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS last_reviewed_at DATETIME; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V26__dream_report.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V26__dream_report.sql new file mode 100644 index 00000000..b4847093 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V26__dream_report.sql @@ -0,0 +1,24 @@ +-- Dream v2: structured dream report (rfc-035 §4.4) +CREATE TABLE IF NOT EXISTS mate_dream_report ( + id BIGINT PRIMARY KEY, + agent_id BIGINT NOT NULL, + mode VARCHAR(32) NOT NULL, + topic VARCHAR(256), + trigger_source VARCHAR(32) NOT NULL, + triggered_by VARCHAR(64), + started_at DATETIME NOT NULL, + finished_at DATETIME NOT NULL, + candidate_count INT NOT NULL, + promoted_count INT NOT NULL, + rejected_count INT NOT NULL, + memory_diff TEXT, + llm_reason TEXT, + status VARCHAR(16) NOT NULL, + error_message TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT DEFAULT 0 +); + +CREATE INDEX idx_dream_agent_time ON mate_dream_report(agent_id, started_at DESC); +CREATE INDEX idx_dream_agent_mode ON mate_dream_report(agent_id, mode, started_at DESC); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V27__memory_recall_review_fields.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V27__memory_recall_review_fields.sql new file mode 100644 index 00000000..f18ad019 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V27__memory_recall_review_fields.sql @@ -0,0 +1,18 @@ +-- Dream v2: candidate state machine fields (rfc-035 §4.1.4) +-- Phase 1 writes values only; filtering enabled in Phase 2. +-- MySQL does not support ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard. +SET @db_name = DATABASE(); + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'review_count'); +SET @stmt = IF(@col_exists = 0, + 'ALTER TABLE mate_memory_recall ADD COLUMN review_count INT DEFAULT 0', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'last_reviewed_at'); +SET @stmt = IF(@col_exists = 0, + 'ALTER TABLE mate_memory_recall ADD COLUMN last_reviewed_at DATETIME', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt b/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt new file mode 100644 index 00000000..48d695b8 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt @@ -0,0 +1,23 @@ +## 现有 MEMORY.md 内容 +``` +{memory} +``` + +## Focused Dream 主题 + +本次整合的重点主题:**{topic}** + +请优先提炼与该主题直接相关的记忆(约 60% 权重),同时也关注非主题类的高分候选(约 40% 权重),避免因主题聚焦而遗漏重要信息。 + +## 高分召回候选(按评分排序) + +{scored_candidates} + +## 最近 {day_range} 天的 daily notes(补充参考) + +{daily_notes} + +--- + +请围绕主题 "{topic}" 分析以上内容,将相关的稳定信息整合到 MEMORY.md 中。 +非主题类的高分候选如有重要内容也一并整合。严格输出 JSON 格式。