feat(memory): Dream v2 Phase 1 engine — consolidate refactor, focused endpoint, monthly archive

Five-commit bundle brings the Dream v2 P1 engine layer online, sitting
on top of the lifecycle mediator foundation already merged.

B.1-B.4 · Schema + records
- Flyway V26 (dream_report) + V27 (memory_recall review fields),
  both h2 and mysql
- DreamReportEntity + DreamMode + DreamStatus enum + record types
- DreamReportMapper repository layer

B.5-B.8 · Consolidate refactor + focused dream
- MemoryEmergenceService refactored for plug-in dream modes
- MemoryRecallService extended with promoted/rejected review fields
- Focused dream endpoint + prompt template
- MemoryController exposes the review/trigger surface

B.9-B.10 · Monthly archive service
- MemoryArchiveService rolls cold promoted entries into archival rows
  and reclaims daily_count storage
- DreamingScheduler runs archive job on its own schedule

B.12-B.14 · Tests
- MemoryArchiveServiceTest
- DreamFlagGuardTest
- DreamV2AcceptanceIT (end-to-end acceptance under feature flag)

Plus a verification script + HTTP e2e kit in the private test/ dir,
used for local staged rollout — not part of the open-source
distribution.

All features stay gated behind the mate.memory.dream.* flags from
Phase 1. Enable per-phase after staging validation.
This commit is contained in:
matevip 2026-04-20 20:21:03 +08:00
parent 907c6eff8c
commit 70c90d814d
18 changed files with 660 additions and 85 deletions

View File

@ -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.
* <p>
* 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<String, StringBuilder> 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<String, StringBuilder> 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<String, StringBuilder> 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 "";
}
}
}

View File

@ -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.mdNIGHTLY 模式")
@PostMapping("/{agentId}/emergence")
@RequireWorkspaceRole("member")
public R<Map<String, String>> triggerEmergence(@PathVariable Long agentId) {
public R<DreamReport> 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<DreamReport> triggerFocusedDream(@PathVariable Long agentId,
@RequestBody Map<String, String> 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")

View File

@ -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;
}

View File

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

View File

@ -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<DreamReportEntity> {
}

View File

@ -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++;

View File

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

View File

@ -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<PromotedEntry> promoted,
List<RejectedEntry> rejected
) {}

View File

@ -0,0 +1,12 @@
package vip.mate.memory.service;
/**
* Dream consolidation result status.
*
* @author MateClaw Team
*/
public enum DreamStatus {
SUCCESS,
FAILED,
SKIPPED
}

View File

@ -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.
* <p>
* 读取近 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<WorkspaceFileEntity> allFiles = workspaceFileService.listFiles(agentId);
List<String> 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<MemoryRecallEntity> 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<Long> 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<PromotedEntry> promotedEntries = new ArrayList<>();
List<RejectedEntry> rejectedEntries = new ArrayList<>();
if (hasScoredCandidates) {
Set<Long> 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<MemoryRecallEntity> allCandidates, List<Long> promotedIds) {
private String buildUserPrompt(DreamMode mode, String topic, String memoryContent,
List<MemoryRecallEntity> 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<MemoryRecallEntity> allCandidates,
List<Long> promotedIds, DreamMode mode, String topic) {
try {
java.util.Set<Long> promotedSet = new java.util.HashSet<>(promotedIds);
Set<Long> promotedSet = new HashSet<>(promotedIds);
List<MemoryRecallEntity> 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<MemoryRecallEntity> 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<PromotedEntry> promoted,
List<RejectedEntry> 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<MemoryRecallEntity> 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() : "";

View File

@ -227,7 +227,7 @@ public class MemoryRecallService {
}
/**
* 标记候选为已提升
* Mark candidates as promoted to MEMORY.md.
*/
public void markPromoted(List<Long> 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<Long> ids) {
if (ids == null || ids.isEmpty()) return;
for (Long id : ids) {
recallMapper.update(null,
new LambdaUpdateWrapper<MemoryRecallEntity>()
.eq(MemoryRecallEntity::getId, id)
.setSql("review_count = COALESCE(review_count, 0) + 1")
.set(MemoryRecallEntity::getLastReviewedAt, java.time.LocalDateTime.now()));
}
}
// ==================== 查询方法 API 使用 ====================
/**

View File

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

View File

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

View File

@ -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);

View File

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

View File

@ -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);

View File

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

View File

@ -0,0 +1,23 @@
## 现有 MEMORY.md 内容
```
{memory}
```
## Focused Dream 主题
本次整合的重点主题:**{topic}**
请优先提炼与该主题直接相关的记忆(约 60% 权重),同时也关注非主题类的高分候选(约 40% 权重),避免因主题聚焦而遗漏重要信息。
## 高分召回候选(按评分排序)
{scored_candidates}
## 最近 {day_range} 天的 daily notes补充参考
{daily_notes}
---
请围绕主题 "{topic}" 分析以上内容,将相关的稳定信息整合到 MEMORY.md 中。
非主题类的高分候选如有重要内容也一并整合。严格输出 JSON 格式。