feat(memory): add Dreaming recall tracking and scored emergence

This commit is contained in:
matevip 2026-04-06 22:25:47 +08:00
parent dd5ec7ff3c
commit 015abcef96
11 changed files with 744 additions and 6 deletions

View File

@ -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<Long, BaseAgent> 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<String> 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<StreamDelta> 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<StreamDelta> 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 : "");

View File

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

View File

@ -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 迁移
* <p>
* 确保 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());
}
}
}

View File

@ -0,0 +1,61 @@
package vip.mate.memory.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 记忆召回追踪实体
* <p>
* 记录 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;
}

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.MemoryRecallEntity;
/**
* 记忆召回追踪 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface MemoryRecallMapper extends BaseMapper<MemoryRecallEntity> {
}

View File

@ -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 定时调度器
* <p>
* 按配置的 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<AgentEntity> 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);
}
}

View File

@ -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<MemoryRecallEntity> 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<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);
}
}
}
} catch (Exception e) {
@ -129,6 +166,48 @@ public class MemoryEmergenceService {
}
}
private String formatScoredCandidates(List<MemoryRecallEntity> 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);

View File

@ -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;
/**
* 记忆召回追踪与评分服务
* <p>
* 记录 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<MemoryRecallEntity>()
.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<String> 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<MemoryRecallEntity>()
.eq(MemoryRecallEntity::getAgentId, agentId)
.eq(MemoryRecallEntity::getDeleted, 0)
.set(MemoryRecallEntity::getDailyCount, 0));
}
/**
* 获取未提升的候选列表
*/
public List<MemoryRecallEntity> listCandidates(Long agentId) {
return recallMapper.selectList(
new LambdaQueryWrapper<MemoryRecallEntity>()
.eq(MemoryRecallEntity::getAgentId, agentId)
.eq(MemoryRecallEntity::getPromoted, false)
.eq(MemoryRecallEntity::getDeleted, 0)
.orderByDesc(MemoryRecallEntity::getScore));
}
/**
* 计算加权评分返回超过阈值的高分候选
*/
public List<MemoryRecallEntity> computeScores(Long agentId) {
List<MemoryRecallEntity> 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<Long> ids) {
if (ids == null || ids.isEmpty()) return;
recallMapper.update(null,
new LambdaUpdateWrapper<MemoryRecallEntity>()
.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<String> 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<String> 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;
}
}
}

View File

@ -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;
/**
* 记忆召回追踪器
* <p>
* 在每次对话时异步记录哪些 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 的文件片段
* <p>
* 仅追踪满足 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<WorkspaceFileEntity> injectedFiles = workspaceFileMapper.selectList(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.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<Integer> 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;
}
}
}

View File

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

View File

@ -0,0 +1,20 @@
## 现有 MEMORY.md 内容
```
{memory}
```
## 高分召回候选(按评分排序)
以下文件在对话中被频繁引用,评分基于召回频率、时效性、查询多样性等维度自动计算。
**优先整合评分高的内容**,它们是用户实际在对话中反复使用的记忆。
{scored_candidates}
## 最近 {day_range} 天的 daily notes补充参考
{daily_notes}
---
请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。
低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。