From 015abcef968e5dafd71e84850f3b9777045b6527 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 6 Apr 2026 22:25:47 +0800 Subject: [PATCH] feat(memory): add Dreaming recall tracking and scored emergence --- .../java/vip/mate/agent/AgentService.java | 8 + .../vip/mate/memory/MemoryProperties.java | 11 + .../memory/config/MemorySchemaMigration.java | 55 ++++ .../mate/memory/model/MemoryRecallEntity.java | 61 +++++ .../memory/repository/MemoryRecallMapper.java | 14 + .../memory/scheduler/DreamingScheduler.java | 60 ++++ .../service/MemoryEmergenceService.java | 91 ++++++- .../memory/service/MemoryRecallService.java | 257 ++++++++++++++++++ .../memory/service/MemoryRecallTracker.java | 151 ++++++++++ .../src/main/resources/db/schema.sql | 22 ++ .../prompts/memory/emergence-scored-user.txt | 20 ++ 11 files changed, 744 insertions(+), 6 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/repository/MemoryRecallMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java create mode 100644 mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index ee7891af..0906a119 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -11,6 +11,7 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; import vip.mate.llm.event.ModelConfigChangedEvent; +import vip.mate.memory.service.MemoryRecallTracker; import java.util.List; import java.util.Map; @@ -31,6 +32,7 @@ public class AgentService { private final AgentMapper agentMapper; private final AgentGraphBuilder agentGraphBuilder; + private final MemoryRecallTracker memoryRecallTracker; /** 运行时 Agent 实例缓存(agentId -> BaseAgent) */ private final Map agentInstances = new ConcurrentHashMap<>(); @@ -73,11 +75,13 @@ public class AgentService { // ==================== 运行时入口 ==================== public String chat(Long agentId, String message, String conversationId) { + memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); return agent.chat(message, conversationId); } public Flux chatStream(Long agentId, String message, String conversationId) { + memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); return agent.chatStream(message, conversationId); } @@ -88,6 +92,7 @@ public class AgentService { public Flux chatStructuredStream(Long agentId, String message, String conversationId, String requesterId) { + memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); if (agent instanceof StructuredStreamCapable capable) { @@ -101,6 +106,7 @@ public class AgentService { } public String execute(Long agentId, String goal, String conversationId) { + memoryRecallTracker.trackRecalls(agentId, goal); BaseAgent agent = getOrBuildAgent(agentId); return agent.execute(goal, conversationId); } @@ -116,6 +122,7 @@ public class AgentService { */ public String chatWithReplay(Long agentId, String userMessage, String conversationId, String toolCallPayload) { + memoryRecallTracker.trackRecalls(agentId, userMessage); BaseAgent agent = getOrBuildAgent(agentId); return agent.chatWithReplay(userMessage, conversationId, toolCallPayload); } @@ -130,6 +137,7 @@ public class AgentService { public Flux chatWithReplayStream(Long agentId, String userMessage, String conversationId, String toolCallPayload, String requesterId) { + memoryRecallTracker.trackRecalls(agentId, userMessage); BaseAgent agent = getOrBuildAgent(agentId); return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload, requesterId != null ? requesterId : ""); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java index 56715ff9..2f697c91 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java @@ -38,4 +38,15 @@ public class MemoryProperties { /** 构建对话 transcript 时的最大消息数(防止过长) */ private int maxTranscriptMessages = 30; + + // ==================== Dreaming 配置 ==================== + + /** 启用定时 Dreaming(自动执行记忆整合) */ + private boolean dreamingEnabled = true; + + /** Dreaming 调度 cron 表达式(Spring 6 字段格式,默认每天凌晨 3 点) */ + private String dreamingCron = "0 0 3 * * ?"; + + /** 记忆召回评分阈值,低于此分的候选不进入 LLM 整合 */ + private double emergenceScoreThreshold = 0.4; } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java b/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java new file mode 100644 index 00000000..dda07701 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java @@ -0,0 +1,55 @@ +package vip.mate.memory.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Memory 模块 Schema 迁移 + *

+ * 确保 mate_memory_recall 表存在(兼容已有部署)。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@Order(201) +@RequiredArgsConstructor +public class MemorySchemaMigration implements ApplicationRunner { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void run(ApplicationArguments args) { + try { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mate_memory_recall ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + filename VARCHAR(256) NOT NULL, + snippet_hash VARCHAR(64), + snippet_preview VARCHAR(512), + recall_count INT NOT NULL DEFAULT 0, + daily_count INT NOT NULL DEFAULT 0, + query_hashes TEXT, + score DOUBLE NOT NULL DEFAULT 0.0, + last_recalled_at DATETIME, + promoted BOOLEAN NOT NULL DEFAULT FALSE, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 + ) + """); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall(agent_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_memory_recall_agent_file ON mate_memory_recall(agent_id, filename)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_memory_recall_score ON mate_memory_recall(agent_id, score)"); + log.info("[MemorySchemaMigration] mate_memory_recall schema migration completed"); + } catch (Exception e) { + log.warn("[MemorySchemaMigration] Migration failed (table may already exist): {}", e.getMessage()); + } + } +} 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 new file mode 100644 index 00000000..c5bf87b7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java @@ -0,0 +1,61 @@ +package vip.mate.memory.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 记忆召回追踪实体 + *

+ * 记录 workspace 文件在对话上下文注入时的召回信息, + * 用于加权评分驱动的记忆整合(Dreaming)。 + * + * @author MateClaw Team + */ +@Data +@TableName("mate_memory_recall") +public class MemoryRecallEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 关联 Agent ID */ + private Long agentId; + + /** 文件名,如 "memory/2026-04-01.md"、"MEMORY.md" */ + private String filename; + + /** 被召回片段的 SHA-256 哈希(可为 null,文件级追踪时不填) */ + private String snippetHash; + + /** 片段预览(前 200 字符,方便调试) */ + private String snippetPreview; + + /** 累计召回次数 */ + private Integer recallCount; + + /** 当日召回次数(每轮 dreaming 重置) */ + private Integer dailyCount; + + /** 不同 user query hash 的 JSON 数组 */ + private String queryHashes; + + /** 加权评分 */ + private Double score; + + /** 最近一次召回时间 */ + private LocalDateTime lastRecalledAt; + + /** 是否已提升到 MEMORY.md */ + private Boolean promoted; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/MemoryRecallMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/MemoryRecallMapper.java new file mode 100644 index 00000000..7ef5abdd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/MemoryRecallMapper.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.MemoryRecallEntity; + +/** + * 记忆召回追踪 Mapper + * + * @author MateClaw Team + */ +@Mapper +public interface MemoryRecallMapper 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 new file mode 100644 index 00000000..35c311c2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java @@ -0,0 +1,60 @@ +package vip.mate.memory.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.service.MemoryEmergenceService; + +import java.util.List; + +/** + * Dreaming 定时调度器 + *

+ * 按配置的 cron 表达式定期执行记忆整合, + * 遍历所有启用的 Agent,对每个 Agent 执行评分驱动的 emergence。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DreamingScheduler { + + private final AgentService agentService; + private final MemoryEmergenceService emergenceService; + private final MemoryProperties properties; + + @Scheduled(cron = "${mate.memory.dreaming-cron:0 0 3 * * ?}") + public void runDreaming() { + if (!properties.isDreamingEnabled()) { + log.debug("[Dreaming] Scheduled dreaming is disabled, skipping"); + return; + } + + log.info("[Dreaming] Starting scheduled dreaming cycle"); + List agents = agentService.listAgents(); + + int success = 0; + int failed = 0; + + for (AgentEntity agent : agents) { + if (!Boolean.TRUE.equals(agent.getEnabled())) { + continue; + } + try { + emergenceService.consolidate(agent.getId()); + success++; + } catch (Exception e) { + failed++; + log.warn("[Dreaming] Failed for agent={} ({}): {}", + agent.getId(), agent.getName(), e.getMessage()); + } + } + + log.info("[Dreaming] Cycle completed: {} succeeded, {} failed", success, failed); + } +} 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 94d883c6..dc35f3ad 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,11 +15,13 @@ 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.MemoryRecallEntity; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; /** * 记忆整合服务 @@ -39,6 +41,7 @@ public class MemoryEmergenceService { private final AgentGraphBuilder agentGraphBuilder; private final MemoryProperties properties; private final ObjectMapper objectMapper; + private final MemoryRecallService recallService; /** * 执行记忆整合:将 daily notes 中的重复模式提炼到 MEMORY.md @@ -84,13 +87,34 @@ public class MemoryEmergenceService { // 3. 读取现有 MEMORY.md String memoryContent = readFileContentSafe(agentId, "MEMORY.md"); - // 4. 构建 prompt 并调用 LLM + // 4. 计算召回评分(必须在 resetDailyCounts 之前,否则 velocity 信号被清零) + List scoredCandidates = recallService.computeScores(agentId); + boolean hasScoredCandidates = !scoredCandidates.isEmpty(); + + // 5. 评分快照完成后再重置 dailyCount,为下一轮积累 + recallService.resetDailyCounts(agentId); + String systemPrompt = PromptLoader.loadPrompt("memory/emergence-system"); - String userTemplate = PromptLoader.loadPrompt("memory/emergence-user"); - String userPrompt = userTemplate - .replace("{memory}", memoryContent) - .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) - .replace("{daily_notes}", dailyNotes); + String userPrompt; + + 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); + } String llmResponse; try { @@ -122,6 +146,19 @@ public class MemoryEmergenceService { workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); String reason = root.path("reason").asText(""); log.info("[Memory] Emergence completed for agent={}: {}", agentId, reason); + + // 逐候选检查:只有内容被 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); + } } } } catch (Exception e) { @@ -129,6 +166,48 @@ public class MemoryEmergenceService { } } + private String formatScoredCandidates(List candidates) { + StringBuilder sb = new StringBuilder(); + for (MemoryRecallEntity entry : candidates) { + sb.append(String.format("### %s (score=%.2f, recalls=%d)\n", + entry.getFilename(), entry.getScore(), entry.getRecallCount())); + if (entry.getSnippetPreview() != null) { + sb.append(entry.getSnippetPreview()); + sb.append("\n\n"); + } + } + 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; + for (String line : lines) { + String trimmed = line.trim(); + 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 ChatModel buildChatModel() { ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); return agentGraphBuilder.buildRuntimeChatModel(defaultModel); 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 new file mode 100644 index 00000000..047a9a6b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java @@ -0,0 +1,257 @@ +package vip.mate.memory.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.model.MemoryRecallEntity; +import vip.mate.memory.repository.MemoryRecallMapper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 记忆召回追踪与评分服务 + *

+ * 记录 workspace 文件的召回频率、查询多样性等信号, + * 计算加权评分用于 Dreaming 记忆整合。 + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemoryRecallService { + + private final MemoryRecallMapper recallMapper; + private final MemoryProperties properties; + private final ObjectMapper objectMapper; + + private static final int MAX_QUERY_HASHES = 32; + + /** + * 记录一次文件召回 + */ + public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash) { + if (agentId == null || filename == null || filename.isBlank()) { + return; + } + + String snippetHash = sha256(snippetText); + String preview = snippetText != null && snippetText.length() > 200 + ? snippetText.substring(0, 200) + : snippetText; + + MemoryRecallEntity existing = recallMapper.selectOne( + new LambdaQueryWrapper() + .eq(MemoryRecallEntity::getAgentId, agentId) + .eq(MemoryRecallEntity::getFilename, filename) + .eq(MemoryRecallEntity::getDeleted, 0) + .last("LIMIT 1")); + + LocalDateTime now = LocalDateTime.now(); + + if (existing != null) { + existing.setRecallCount(existing.getRecallCount() + 1); + existing.setDailyCount(existing.getDailyCount() + 1); + existing.setLastRecalledAt(now); + existing.setSnippetHash(snippetHash); + existing.setSnippetPreview(preview); + + // 追加 query hash(去重,最多 MAX_QUERY_HASHES 个) + if (userQueryHash != null) { + List hashes = parseQueryHashes(existing.getQueryHashes()); + if (!hashes.contains(userQueryHash) && hashes.size() < MAX_QUERY_HASHES) { + hashes.add(userQueryHash); + } + existing.setQueryHashes(toJson(hashes)); + } + + recallMapper.updateById(existing); + } else { + MemoryRecallEntity entity = new MemoryRecallEntity(); + entity.setAgentId(agentId); + entity.setFilename(filename); + entity.setSnippetHash(snippetHash); + entity.setSnippetPreview(preview); + entity.setRecallCount(1); + entity.setDailyCount(1); + entity.setLastRecalledAt(now); + entity.setPromoted(false); + entity.setScore(0.0); + entity.setCreateTime(now); + entity.setUpdateTime(now); + entity.setDeleted(0); + + if (userQueryHash != null) { + entity.setQueryHashes(toJson(List.of(userQueryHash))); + } + + recallMapper.insert(entity); + } + } + + /** + * 重置所有记录的 dailyCount(在每轮 dreaming 开始时调用) + */ + public void resetDailyCounts(Long agentId) { + recallMapper.update(null, + new LambdaUpdateWrapper() + .eq(MemoryRecallEntity::getAgentId, agentId) + .eq(MemoryRecallEntity::getDeleted, 0) + .set(MemoryRecallEntity::getDailyCount, 0)); + } + + /** + * 获取未提升的候选列表 + */ + public List listCandidates(Long agentId) { + return recallMapper.selectList( + new LambdaQueryWrapper() + .eq(MemoryRecallEntity::getAgentId, agentId) + .eq(MemoryRecallEntity::getPromoted, false) + .eq(MemoryRecallEntity::getDeleted, 0) + .orderByDesc(MemoryRecallEntity::getScore)); + } + + /** + * 计算加权评分,返回超过阈值的高分候选 + */ + public List computeScores(Long agentId) { + List candidates = listCandidates(agentId); + if (candidates.isEmpty()) { + return Collections.emptyList(); + } + + // 归一化参数 + int maxRecallCount = candidates.stream() + .mapToInt(MemoryRecallEntity::getRecallCount) + .max().orElse(1); + int maxQueryDiversity = candidates.stream() + .mapToInt(e -> parseQueryHashes(e.getQueryHashes()).size()) + .max().orElse(1); + + LocalDateTime now = LocalDateTime.now(); + double halfLifeDays = 7.0; + double threshold = properties.getEmergenceScoreThreshold(); + + for (MemoryRecallEntity entry : candidates) { + // 1. 频率 (0.30) + double frequency = (double) entry.getRecallCount() / Math.max(maxRecallCount, 1); + + // 2. 时效性 (0.25) — 指数衰减 + double recency = 0.0; + if (entry.getLastRecalledAt() != null) { + long daysSinceRecall = ChronoUnit.DAYS.between(entry.getLastRecalledAt(), now); + recency = Math.exp(-0.693 * daysSinceRecall / halfLifeDays); // ln(2) ≈ 0.693 + } + + // 3. 查询多样性 (0.20) + int queryCount = parseQueryHashes(entry.getQueryHashes()).size(); + double diversity = (double) queryCount / Math.max(maxQueryDiversity, 1); + + // 4. 内容新鲜度 (0.15) — 根据文件名日期 + double freshness = computeFreshness(entry.getFilename(), now); + + // 5. 召回速度 (0.10) — dailyCount / recallCount + double velocity = entry.getRecallCount() > 0 + ? (double) entry.getDailyCount() / entry.getRecallCount() + : 0.0; + + double score = 0.30 * frequency + + 0.25 * recency + + 0.20 * diversity + + 0.15 * freshness + + 0.10 * velocity; + + entry.setScore(score); + recallMapper.updateById(entry); + } + + return candidates.stream() + .filter(e -> e.getScore() >= threshold) + .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .collect(Collectors.toList()); + } + + /** + * 标记候选为已提升 + */ + public void markPromoted(List ids) { + if (ids == null || ids.isEmpty()) return; + recallMapper.update(null, + new LambdaUpdateWrapper() + .in(MemoryRecallEntity::getId, ids) + .set(MemoryRecallEntity::getPromoted, true)); + } + + // ==================== 内部工具方法 ==================== + + private double computeFreshness(String filename, LocalDateTime now) { + // 从 "memory/2026-04-01.md" 或 "memory/2026-04-01.md#section" 提取日期 + if (filename == null || !filename.startsWith("memory/")) { + return 0.5; // 非 daily note 给中间值 + } + try { + String datePart = filename.replace("memory/", ""); + // 剥离 #anchor(片段级追踪产生的 section key) + int hashIdx = datePart.indexOf('#'); + if (hashIdx > 0) { + datePart = datePart.substring(0, hashIdx); + } + datePart = datePart.replace(".md", ""); + String[] parts = datePart.split("-"); + if (parts.length == 3) { + LocalDateTime fileDate = LocalDateTime.of( + Integer.parseInt(parts[0]), + Integer.parseInt(parts[1]), + Integer.parseInt(parts[2]), + 0, 0); + long daysAgo = ChronoUnit.DAYS.between(fileDate, now); + return Math.max(0, 1.0 - (double) daysAgo / 30.0); // 30 天线性衰减 + } + } catch (Exception ignored) { + } + return 0.5; + } + + private List parseQueryHashes(String json) { + if (json == null || json.isBlank()) return new ArrayList<>(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + private String toJson(List list) { + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + return "[]"; + } + } + + private String sha256(String text) { + if (text == null || text.isBlank()) return null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (Exception e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java new file mode 100644 index 00000000..3ba00062 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java @@ -0,0 +1,151 @@ +package vip.mate.memory.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import vip.mate.workspace.document.model.WorkspaceFileEntity; +import vip.mate.workspace.document.repository.WorkspaceFileMapper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 记忆召回追踪器 + *

+ * 在每次对话时异步记录哪些 workspace 文件/片段被实际注入到上下文中。 + * 追踪粒度为片段级(daily note 按 ## 标题拆分),而非文件级。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemoryRecallTracker { + + private final MemoryRecallService recallService; + private final WorkspaceFileMapper workspaceFileMapper; + + /** 用于拆分 daily note 中的二级标题片段 */ + private static final Pattern SECTION_PATTERN = Pattern.compile("(?m)^## .+"); + + /** + * 异步追踪一次对话中被实际注入到 system prompt 的文件片段。 + *

+ * 仅追踪满足 buildSystemPrompt 注入条件的文件(enabled=true, content 非空)。 + * 对 daily note 类文件(memory/*.md),按 ## 标题拆分为独立片段追踪。 + * + * @param agentId Agent ID + * @param userQuery 用户消息(用于计算 query hash) + */ + @Async + public void trackRecalls(Long agentId, String userQuery) { + try { + // 精确复现 buildSystemPrompt 的注入条件 + List injectedFiles = workspaceFileMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getEnabled, true) + .isNotNull(WorkspaceFileEntity::getContent) + .ne(WorkspaceFileEntity::getContent, "") + .orderByAsc(WorkspaceFileEntity::getSortOrder)); + + if (injectedFiles.isEmpty()) { + return; + } + + String queryHash = sha256Short(userQuery); + int trackedCount = 0; + + for (WorkspaceFileEntity file : injectedFiles) { + String content = file.getContent(); + if (content == null || content.isBlank()) { + continue; + } + + String filename = file.getFilename(); + + if (filename.startsWith("memory/") && filename.endsWith(".md")) { + // daily note: 按 ## 标题拆分为独立片段 + trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash); + } else { + // 非 daily note (PROFILE.md, MEMORY.md 等): 文件级追踪 + recallService.recordRecall(agentId, filename, content, queryHash); + trackedCount++; + } + } + + log.debug("[MemoryRecall] Tracked {} snippets for agent={}", trackedCount, agentId); + } catch (Exception e) { + log.warn("[MemoryRecall] Failed to track recalls for agent={}: {}", agentId, e.getMessage()); + } + } + + /** + * 将 daily note 按 ## 标题拆分为独立片段,分别追踪 + */ + private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash) { + Matcher matcher = SECTION_PATTERN.matcher(content); + List sectionStarts = new java.util.ArrayList<>(); + while (matcher.find()) { + sectionStarts.add(matcher.start()); + } + + if (sectionStarts.isEmpty()) { + // 没有 ## 标题,整个文件作为一个片段 + recallService.recordRecall(agentId, filename, content.trim(), queryHash); + return 1; + } + + int count = 0; + // 如果 ## 前有内容,作为第一个片段 + if (sectionStarts.get(0) > 0) { + String preamble = content.substring(0, sectionStarts.get(0)).trim(); + if (!preamble.isEmpty()) { + recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash); + count++; + } + } + + for (int i = 0; i < sectionStarts.size(); i++) { + int start = sectionStarts.get(i); + int end = (i + 1 < sectionStarts.size()) ? sectionStarts.get(i + 1) : content.length(); + String snippet = content.substring(start, end).trim(); + if (!snippet.isEmpty()) { + // 从 ## 标题行提取 section 标识 + String firstLine = snippet.contains("\n") ? snippet.substring(0, snippet.indexOf('\n')).trim() : snippet; + String sectionKey = filename + "#" + sanitizeSectionKey(firstLine); + recallService.recordRecall(agentId, sectionKey, snippet, queryHash); + count++; + } + } + return count; + } + + private String sanitizeSectionKey(String heading) { + // "## Some Title" -> "some-title" + return heading.replaceFirst("^#+\\s*", "") + .toLowerCase() + .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-") + .replaceAll("^-|-$", ""); + } + + private String sha256Short(String text) { + if (text == null || text.isBlank()) return null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (int i = 0; i < 8 && i < hash.length; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (Exception e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index 9d37d31f..60681c0a 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -409,3 +409,25 @@ ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS metadata JSON; CREATE INDEX IF NOT EXISTS idx_guard_audit_conv ON mate_tool_guard_audit_log(conversation_id); CREATE INDEX IF NOT EXISTS idx_guard_audit_time ON mate_tool_guard_audit_log(create_time); + +-- 记忆召回追踪表(Dreaming 评分驱动记忆整合) +CREATE TABLE IF NOT EXISTS mate_memory_recall ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + filename VARCHAR(256) NOT NULL, + snippet_hash VARCHAR(64), + snippet_preview VARCHAR(512), + recall_count INT NOT NULL DEFAULT 0, + daily_count INT NOT NULL DEFAULT 0, + query_hashes TEXT, + score DOUBLE NOT NULL DEFAULT 0.0, + last_recalled_at DATETIME, + promoted BOOLEAN NOT NULL DEFAULT FALSE, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall(agent_id); +CREATE INDEX IF NOT EXISTS idx_memory_recall_agent_file ON mate_memory_recall(agent_id, filename); +CREATE INDEX IF NOT EXISTS idx_memory_recall_score ON mate_memory_recall(agent_id, score); diff --git a/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt b/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt new file mode 100644 index 00000000..d42ce039 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt @@ -0,0 +1,20 @@ +## 现有 MEMORY.md 内容 +``` +{memory} +``` + +## 高分召回候选(按评分排序) + +以下文件在对话中被频繁引用,评分基于召回频率、时效性、查询多样性等维度自动计算。 +**优先整合评分高的内容**,它们是用户实际在对话中反复使用的记忆。 + +{scored_candidates} + +## 最近 {day_range} 天的 daily notes(补充参考) + +{daily_notes} + +--- + +请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。 +低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。 \ No newline at end of file